diff --git a/.github/workflows/agents-executor.yml b/.github/workflows/agents-executor.yml index 3d0ca6505..8dbdc8311 100644 --- a/.github/workflows/agents-executor.yml +++ b/.github/workflows/agents-executor.yml @@ -43,9 +43,7 @@ jobs: ~/.parsar/cache/executor-cargo ~/.parsar/cache/executor-target key: agents-executor-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-executor/Cargo.lock') }} - - name: Check native launcher and independent release build + - name: Check shared workspace helpers and independent release build run: | make check-agents-executor make build-agents-executor - ~/.parsar/build/agents-executor/agents-api-codex-executor --version - ~/.parsar/build/agents-executor/agents-api-codex-executor --help diff --git a/.github/workflows/agents-harness.yml b/.github/workflows/agents-harness.yml deleted file mode 100644 index e9ebd90bd..000000000 --- a/.github/workflows/agents-harness.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: agents-harness - -on: - push: - branches: [main] - paths: - - 'packages/codex-harness/**' - - 'scripts/*agents-harness.sh' - - 'services/agents-api/tests/native/*/source.json' - - '.github/workflows/agents-harness.yml' - - 'Makefile' - pull_request: - paths: - - 'packages/codex-harness/**' - - 'scripts/*agents-harness.sh' - - 'services/agents-api/tests/native/*/source.json' - - '.github/workflows/agents-harness.yml' - - 'Makefile' - -permissions: - contents: read - -concurrency: - group: agents-harness-${{ github.ref }} - cancel-in-progress: true - -jobs: - native-build: - runs-on: ubuntu-22.04 - timeout-minutes: ${{ matrix.timeout }} - strategy: - fail-fast: false - matrix: - include: - - target: check-agents-harness-native - timeout: 60 - - target: build-agents-harness - timeout: 120 - env: - CARGO_HOME: /home/runner/.parsar/cache/agents-harness-cargo - CARGO_TARGET_DIR: /home/runner/.parsar/cache/agents-harness-target - AGENTS_HARNESS_NATIVE_SOURCE: /home/runner/.parsar/references/codex-native - CARGO_BUILD_JOBS: 4 - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_INCREMENTAL: 0 - steps: - - uses: actions/checkout@v7 - - name: Reserve disk for native compilation - run: | - # These preinstalled SDKs are unused by the Rust-only job. - sudo rm -rf /usr/share/dotnet /usr/local/lib/android - df -h "$HOME" - - name: Check packaging - run: make check-agents-harness - - 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 - - name: Fetch the pinned upstream source - run: | - revision="$(python3 -c 'import json; print(json.load(open("packages/codex-harness/source.json"))["revision"])')" - git init "$AGENTS_HARNESS_NATIVE_SOURCE" - git -C "$AGENTS_HARNESS_NATIVE_SOURCE" fetch --depth 1 https://github.com/openai/codex "$revision" - - uses: actions/cache@v6 - with: - path: | - ~/.parsar/cache/agents-harness-cargo/registry - ~/.parsar/cache/agents-harness-cargo/git - key: agents-harness-deps-v2-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-harness/source.json') }} - - name: Run native target - env: - NATIVE_TARGET: ${{ matrix.target }} - run: make "$NATIVE_TARGET" - - name: Report disk usage - if: always() - run: | - df -h "$HOME" - du -sh "$CARGO_HOME" "$CARGO_TARGET_DIR" 2>/dev/null || true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 581675ef8..f4189a85a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,9 +35,8 @@ split oversized components before extending them. Use `internal/obs/log` for log Run `make check` before completion. The standalone gate includes all daemon/shared Go tests, Core contract/client/service tests, a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK -tests and packaging, MiniMax companion checks, Rust executor tests/format/Clippy, -and Codex Harness packaging checks. It intentionally has no product Web/server/ -installer gates. The full gate fails when the database variable is missing. +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. 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 @@ -49,20 +48,13 @@ Run `make openapi` after handler annotation changes. It reuses the original Core-only swaggo v1.16.4 generator and writes this schema, without product routes. Core changes must retain the independent build and official-client workflow. -Changes to native Harness sources require `make check-agents-harness-native`, -`make build-agents-harness` and applicable live provider acceptance. Real execution -checks require real models; do not count omitted prerequisites or mocked responses -as live acceptance. Never expand this extraction into unrelated behavioral fixes. - -Native Harness CI runs checks and the release build on separate disposable runners -so debug and release artifacts do not compete for disk. It disables incremental -compilation and caches downloaded Cargo dependencies, not target directories. -The Rust-only jobs remove unused preinstalled Android/.NET SDKs and report disk -usage. Both matrix targets must pass; release optimization settings and native -test/Clippy coverage remain unchanged. These resource settings apply to CI, not -operator build defaults. Checks retain a 60-minute limit; cold optimized release -builds receive 120 minutes after the standard runner exceeded one hour with disk -space remaining. A timeout is still a failed build, not a skipped gate. +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. ## Architecture boundaries @@ -200,19 +192,28 @@ identity and prior API Turn state belong to `SessionExecutionBinding`. Platform-managed and user-managed deployment reuse this same Runtime. For platform management, SandboxProvider creates and reclaims it. For user management, the user starts the Runtime and its daemon authenticates and initiates the Core connection; -Core must verify tenant ownership and the exact Environment binding. These are -management responsibilities, not separate execution architectures. User-managed -Runtime does not automatically mean the official `self_hosted` discriminator; -that mapping needs separate protocol definition and acceptance. User-managed -installation and enrollment remain later board work, outside the current Docker -co-location security qualification. - -Preserve the accepted official `self_hosted` interoperability path and its native -executor connection flow. Codex registry/Noise is specific to that path, not the -V1 hosted backbone or a universal protocol for all engines. A private daemon URL -or an undocumented daemon installation requirement cannot replace `remote_url`. -Keep harness cwd separate from the executor workspace where that accepted remote -path still requires it. +Core verifies principal ownership and the exact Environment binding. These are +management responsibilities, not separate execution architectures. + +In V1, our daemon fills the user-side executor role. Users deploy daemon, the +selected harness, local tools and workspace together. Do not require Codex +`exec-server`, a service-side harness, registry/Noise transport or remote tool +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.3.0 after removal of remote execution fields. +Deploy Core and daemon together; the existing major/minor WebSocket check rejects +0.2 peers before dispatch rather than ignoring their removed configuration. + +User-managed onboarding creates a `self_hosted` Session first, then passes its +Environment ID and unchanged `remote_url` to our Runtime with connect-only +authorization. This is our daemon connection contract, not stock OpenAI +`exec-server` transport compatibility. Validate the pinned public HTTP/SDK +resources, state transitions and lifecycle separately; do not infer complete +compatibility from a working connection. User-side tooling owns local Runtime or +E2B allocation, renewal and cleanup. Session deletion and credential revocation do +not transfer ownership of user compute to Core or prove process quiescence. Public Environment Templates belong to Core and its execution database, independently of provider image/build templates. Resolve a tenant-owned reference once at Session @@ -382,36 +383,21 @@ does not remove service-owned hosted expiry and cleanup requirements. The official `openai_hosted` discriminator means hosting by this independent Core service, using Docker V1. Keep the public value unchanged; `parsar_hosted` is not a new API type. Public Environment Templates apply only to this hosted path. -E2B onboarding follows the official `self_hosted` workflow: an application or -webhook controller owns sandbox provisioning and cleanup, and the executor connects +E2B onboarding follows the application-managed `self_hosted` resource workflow: +the application owns sandbox provisioning and cleanup, and our daemon connects with the returned Environment ID, unchanged `remote_url` and scoped environment -authorization. Reuse existing Runtime and provider components without a separate -public integration design. A private daemon connection alone is not evidence of -official interoperability. Qualify tenant ownership, credentials and connection -lifecycle using the pinned client and actual execution. - -The previously accepted Core-managed E2B route remains implementation evidence -pending bounded realignment and obsolete-route cleanup after Environment Templates. -Do not expand it as a second hosted offering. Current Template acceptance uses -Docker; historical E2B tests retain only their demonstrated scope. - -The existing E2B Provider uses an explicit `templateID:build_UUID` and the same qualified -colocated Runtime. Its root-private bootstrap input and final atomic receipt live -on persistent disk, never template `/run`. Running compute alone does not establish -completed initialization. Inspect exact installation/tenant/Environment/allocation -metadata and the matching Session/device receipt; never replay uncertain Create or -bootstrap. Credentials stay out of provider metadata, template environment and -command arguments. Use the existing one-hour disconnect grace with an E2B lease of -at least two hours. Expiry, pause or lost state cannot silently recreate/resume a -VM. Before launching daemon, trusted root bootstrap must correct E2B's writable -program/boot paths and disable its unused passwordless privileged account; qualify -these protections after provider finalization, not just in the source image. -The [E2B operator guide](services/agents-api/deploy/e2b/README.md) owns packaging, -configuration and real-cloud acceptance. The pinned official envd process schema -and generated Go messages live together under `internal/sandbox/e2b/envdprocess`; -regenerate with the documented tools when that source changes. Do not hand-write -Connect framing or add SDK subprocesses to the static Core. Provider envd file and -command access is initialization-only; public Files and execution remain on Runtime. +authorization. Reuse the same Runtime and thin provider components. No OpenAI +executor process or additional execution architecture is required. Qualify +principal/tenant ownership, credentials and connection lifecycle using the pinned +client and actual execution; document our transport boundary explicitly. + +The Core-managed E2B Provider, including its custom HTTP/Connect and envd protocol +implementation, is retired. User-side E2B tooling uses the official SDK and the +shared Runtime, not an additional execution architecture. The +[E2B guide](services/agents-api/deploy/e2b/README.md) owns packaging and user-managed +allocation, renewal and cleanup. Historical Core-managed E2B acceptance retains +only its original scope; it does not qualify the new enrollment path. Current +public Template acceptance uses Docker. The independent Docker Provider consumes an immutable Runtime image and retains one caller-owned allocation reference through partial creation and cleanup. Persist @@ -536,12 +522,12 @@ device credential. Revocation does not authorize silent placement replacement. The private local Environment reference contains its identity and, for policy-aware execution, its immutable network policy. Trusted Runtime deployment configuration freezes the Environment, Session and workspace root; -requests cannot supply a replacement root. Local and remote references are mutually -exclusive. Use the same preparation/start lifecycle for native execution and the +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 do not admit public hosted requests, -establish Provider lifecycle, or define the official `self_hosted` mapping. +temporary harness. These private capabilities alone do not authorize public requests or establish +Provider lifecycle. Self-hosted enrollment supplies the exact local binding. Core rechecks the persisted Environment/device binding for preparation and active reads; capability discovery cannot select or authorize a general device for this placement. Local work uses the existing pending-input reservation and Worker @@ -555,9 +541,10 @@ harness's native implementation behind its adapter. Core acts on verified capabi conditions; a capability declaration alone never grants public feature admission. Extend existing interfaces during related functional work without introducing a second framework or a broad rewrite. Codex, Claude Code and MiniMax Code have -qualified dedicated Docker and E2B V1 profiles. Each harness has equal standing; +qualified dedicated Docker profiles and historical Core-managed E2B evidence. +New user-managed enrollment requires separate real acceptance. Each harness has equal standing; qualify each image/template with the common full-loop acceptance before deploying. -Additional engines and hosted remote-executor separation remain separate work. +Additional engines remain separate work; V1 has no separate remote executor. Later engines must satisfy the same applicable acceptance contract while keeping their suitable native deployment layout. @@ -696,17 +683,15 @@ backfilled. Creation and recorded-intent retry snapshots load the Environment wi 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 a `self_hosted` Session on the Codex profile when -execution and a validated executor origin are configured. Require an absolute -POSIX workspace directory without NUL, CR, LF or backslash for the current adapter; -omitted/null capability directories use the empty default. -Supported non-deferred function tools use the existing validation and native -callback bridge. Nonempty capability directories and other engine placements -remain rejected implementation gaps. Session output uses the owned -Environment association; file operations and populated installation metadata remain separate. +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 +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. Environment retrieval uses the existing tenant-scoped join to a live owning Session -and its durable connection status, independently of execution or registry setup. +and its durable connection status, independently of a live Runtime or gateway setup. It preserves project-shared read access and exposes only the pinned resource fields. The current closed self-hosted configuration has no API-managed file, plugin or skill installations, so those required arrays are empty. They are not a filesystem listing @@ -805,8 +790,8 @@ earlier failed Turn. This local settlement policy does not establish hosted expi errors or initial-input asynchronous failure semantics; those remain unverified. Session GET/list/metadata responses and live SSE share the safe `self_hosted` -output projection. Its `remote_url` comes only from the executor registry's -validated configured origin, never request headers or a daemon address. Include +output projection. Its `remote_url` comes only from the daemon gateway +configuration, never request headers or a daemon address. Include the owned Environment ID, workspace and capability directories without exposing private configuration. The standalone Environment resource remains separate. Acceptance must pass that exact URL and ID to the @@ -829,154 +814,32 @@ real remote commands/files and a second native-history Turn. Private provisionin or injected API handlers cannot substitute for that workflow. -The opt-in native Codex executor registry lives in -`services/agents-api/internal/executor/codex`, outside public API handlers and the -daemon device gateway. It reuses the worker's execution lease and Store ownership -reads. The operator issues connect-only executor keys for a complete typed principal -within an already verified project-to-tenant mapping. A key has a stable explicit -management UUID, immutable principal and optional exact-Environment restriction; -a principal key needs no Session at issuance. Only its digest, creation/issuance -times and revocation state are persisted. Ordinary issuance never replaces an ID; -rotation and revocation require that ID and full principal. Exact-target issuance -and rotation share the Session deletion lock and require its recorded creator. - -Every authorization checks the current digest, non-revocation, project partition, -Session creator kind/ID, optional restriction and live Session in one database -snapshot. Unknown historical creators cannot authorize an executor. Deleting one -Session denies that target without revoking a principal key serving other Sessions. -Keys have no connection-ticket expiry; their validity ends through explicit -rotation/revocation, while each target remains subject to current ownership checks. -Keep caller, device, harness and executor credentials independent; no raw-token -import or read-back is provided. Never log registry bearer or URL capabilities. - -Migration 26 retains legacy key digests/restrictions under their Environment UUIDs -but revokes them with unknown principals. Do not infer historical identities or -project mappings. Stop older registry and operator writers before migration; -deploy the issuer, registry and launcher together, explicitly reissue keys and -restart executors. Reserved legacy IDs cannot be claimed or rotated into principal -keys. Downgrade cannot discard new principal-key identities or undo revocation. - -Registration IDs, five-minute connection capabilities and socket generations are -process-local. Re-registration replaces the current socket; late close callbacks -cannot clear its successor. Restart invalidates old URLs and requires registration -again. An executor retaining a valid credential may register again: permanently -excluding it requires key revocation/rotation. The current executor digest is rechecked for registration, validation, socket -attachment and live socket heartbeats; rotation/revocation applies without restart. -Registration replacement orders credential observations so an older request cannot -overwrite a newer credential's registration. Heartbeats run every five seconds -with a four-second authorization budget; closing sockets is an observation bound, -not immediate revocation of remote side effects. Ownership is also rechecked; failed execution ownership closes the registry. This -is bounded connection observation, not a guarantee of native process quiescence. -Durable connection state and immutable Environment event snapshots follow the -leased observation path below; a socket never establishes harness readiness. The harness registry grants a distinct, exact-Environment credential access to -native `/connect`; the executor alone calls `/validate`. Each connection URL and -one-use key authorization are separate five-minute capabilities bound to the -current registration, executor socket and complete harness public key. Grants are -bounded; refresh may issue unused grants without disturbing an active pair. - -Internal execution owners obtain random harness credentials from the native -registry after current lease and exact tenant/Environment authorization. The -registry retains at most 32 credential digests in memory. The owner context spans -preparation and its transferred Run; release, owner cancellation and registry -shutdown invalidate that credential, its pending grants and its own connected pair. -Recheck the same live credential under the registry lock after authorization -queries in connect, attach and validation. Old cleanup cannot revoke a successor. -The five-minute connection-ticket lifetime does not expire an active execution -owner or impose a Turn deadline. Pair closure is not proof of OS quiescence. -Static harness-key files are retired explicitly, without a fallback or public -issuance endpoint. Executor authorization also requires the recorded Session -creator; tenant ownership alone cannot authorize executor connections. No credential bearer belongs in snapshots, events, logs or the database. - -One independent harness connection pairs with each executor connection. Native -binary messages pass unchanged, up to the pinned 256 KiB limit, with one data -writer and one in-flight message per direction. Write deadlines bound stalled -peers. Either peer disconnecting closes both physical sockets and invalidates the -pair's grants; this lets native Session/process recovery run in the executor. -Never forward queued ciphertext to a replacement or invent transport replay. -Concurrent native commands and files share one connection; additional independent -harnesses are rejected without eviction. Full public Environment conformance and other engine -placements remain separate work. Native transport annotations are excluded from the -pinned public SDK OpenAPI output; their routes are documented in the service guide. - -Remote file operations must share the native execution owner's filesystem and -authorized connection. The pinned stock app-server `fs/*` methods select its local -Environment and cannot access an executor-only workspace. The opt-in -[shared-filesystem probe](services/agents-api/tests/native/README.md#shared-native-filesystem-owner) -instead injects one upstream `EnvironmentManager` into the native in-process -app-server and uses its typed filesystem directly. This is a prerequisite -experiment, not a production daemon selection or public file implementation. -The pinned in-process transport can silently drop notifications under saturation; -absence of a `Lagged` event does not prove lossless delivery. Resolve that event -contract and process/authorization ownership before adopting an embedded runtime. -Public workspace paths, file references, live metadata and pagination require -separate protocol acceptance; no model prompt or shell command implements file IO. - -The opt-in [raw manager qualification](services/agents-api/tests/native/raw_manager/README.md) -tracks an explicit patch against the same native pin. It publishes the raw runner's -stock-built manager through an additive entrypoint, retaining native configuration, -processor and transport assembly. The ordinary runner remains unchanged. Handle -publication is not readiness or revocation; its owner must supervise runner failure, -gate operations on initialization/readiness and release retained handles on teardown. -This is a private native dependency experiment, not a production runtime selection. -Record the patch, build overlay and artifact identities separately from upstream. -Production adoption requires real acceptance of the requested operations against -the remote workspace/history, bounded ownership and caller authorization. Require -stale-write fencing when admitting mutations or replacing their owner, rather than -making it a prerequisite for every read. Connection observation generations alone -cannot retract already-issued filesystem mutations. - -The opt-in [private harness artifact](packages/codex-harness/README.md) consumes -that same hook in a separately named executable at the unchanged native pin. -Its canonical patch lives in the package; qualification manifests reference the -same bytes. Export the exact upstream commit, verify the lock normalization and -named-binary overlay, and retain source/toolchain/artifact provenance. Do not build -from a mutable upstream worktree or present this integration as a stock binary. -Capture operator selectors before native bootstrap; retain native dotenv/helper -initialization before threads and its alias guard until runtime teardown. -The existing Go RPC owns its raw stdio child. A private same-user local socket -offers metadata and bounded reads through that runner's manager, with a frozen registry -Environment UUID, the adapter's native `remote` manager key, and no local fallback. -Keep socket admission bounded and stop it when the runner ends. Caller disconnect -only stops response delivery. Runner completion stops pending frames/new admission -and drains the already admitted operation within its original deadline before local -release; an unresolved drain remains an owner failure. This retains a native wait, -not a remote retirement guarantee. External forced child exit can interrupt the -drain; the existing daemon RPC's short grace/local-reap contract must be reconciled -before a file consumer can infer settlement from release. An unresolved native -file timeout must stop the owner before admitting another operation; client -frame/response timeouts are connection-local. Never equate dropping the native -response future with remote settlement. Bound Tokio runtime shutdown so an -uncancellable native stdin read cannot hide local process exit from the RPC owner. -The separately hashed bounded-read hook pins one existing native RPC connection -for open, sequential block reads and acknowledged close. It retains the pinned -native wire and stock stream behavior. Bound returned bytes and use one-byte -lookahead for exact/truncated results; do not promise a file snapshot. Uncertain -open/read results remain uncertain even if a later close replies. Unconfirmed -close fails the owner, with no partial success or connection replacement retry. -These are private adapter outcomes, not new official Files fields or error semantics. -The socket directory -must be new and private under `~/.parsar`; native/helper/socket selectors remain -operator configuration. `PARSAR_CODEX_HARNESS_BIN` opts the native Codex adapter -into this artifact for validated remote preparations only; stock helper discovery -and all nonremote execution remain unchanged. The adapter derives each private -Environment/workspace binding and owns a short IPC directory under canonical -`~/.parsar`, independently of deeper `PARSAR_HOME` profiles. Reuse the existing -Prepared-to-Session transfer and RPC child; remove IPC only after that same child -has been reaped, including initialization failure and Close timeouts. No wrapper, -new capability, public Files admission or default daemon selection is introduced. Private file path checks do not qualify filesystem isolation, idle -ownership, remote retirement, or the existing RPC's full backpressure behavior. -Public cancellation qualification for this artifact reuses the fixed SDK/raw HTTP -fixture with a task-isolated native system configuration and independently observed -owners. Preserve existing behavioral assertions and keep that test placement -separate from production isolation or public Files admission; see the -[native acceptance guide](services/agents-api/tests/native/README.md#public-cancellation-with-the-optional-harness). +User-managed Runtime enrollment authenticates the existing principal executor key +against the exact live Session/Environment and its recorded creator. Keys retain +stable management IDs, immutable principals, optional exact-Environment restrictions, +rotation/revocation and digest-only storage. They grant connection authority, never +Session API access. No raw-token import or secret read-back is added. + +Enrollment atomically creates or recovers one dedicated device and immutable Session +binding under the Session lock. The V1 workspace is `/workspace`; nonempty +self-hosted capability directories remain unsupported. No `runtime_allocation` is +created for user-owned compute. A retry cannot replace a device, change its bound +key or adopt another native history. Gateway authentication and dispatch recheck +current key authority; rotation/revocation and deletion deny further use. + +The daemon, selected harness, local tools and workspace run together. The Dispatcher +passes the existing typed `LocalEnvironment` after exact tenant/Session/Environment/ +device checks. Native preparation, Files and Artifacts reuse the same protected +local workspace and existing lifecycle owners. There is no registry/Noise relay, +transient harness credential, service-side harness or remote tool forwarding path. +Keep model credentials, daemon authorization and native histories private; connection +success alone establishes neither native readiness nor filesystem isolation. The private daemon `workspace_read` control targets an existing preparation handle or its transferred active Run on the same authenticated device connection. Require the exact frozen Environment identity; callers cannot supply sockets, credentials or workspace roots. Shared routing uses the optional `agent.WorkspaceReader` -interface, without selecting an engine by name. The optional Codex artifact uses -its existing same-manager socket; stock Codex and other adapters remain unsupported. +interface, without selecting an engine by name. The same control accepts `operation: directory` through the optional `agent.WorkspaceDirectoryLister`, with mutually exclusive byte/entry limits and typed directory metadata. Directory responses carry at most 1024 single-component @@ -984,12 +847,7 @@ UTF-8 names of at most 255 bytes, so escaped metadata stays below the existing frame bound. These are private transport limits, not public Files parameters. Byte and directory operations share target checks, correlation, capacity and retained operation waits; neither creates a Run or selects an engine by name. -Current bound preparation admission requires `RemoteEnvironment`; the qualified -co-located Claude workspace profile does not accept that placement. Its private -directory capability therefore does not establish end-to-end control admission. -Integrate its verified workspace identity through the existing lifecycle before -claiming Claude control/public Files acceptance; never fabricate remote bindings. -This control is not a public Files endpoint or capability advertisement. +This control does not itself authorize a public Files endpoint or placement. The separate optional `agent.WorkspaceDirectoryLister` observes one workspace-relative directory on the existing Prepared/Session owner; empty path selects its root. @@ -997,27 +855,15 @@ Return single-component names, entry kind, regular-file byte size and explicit truncation only after directory/metadata access and handle cleanup settle. Reuse byte-read admission, uncertainty and caller-detach ownership where applicable. Do not promise a snapshot, recursive traversal or public pagination through this -private interface. Codex uses the same manager's native process backend to invoke -an operator-installed `agents-api-codex-directory` through explicit argv, without a -shell. `PARSAR_CODEX_DIRECTORY_HELPER` selects the executor-side executable and is -frozen in the private child binding; an absent or invalid selector rejects only -this operation. Keep that qualified installation outside the writable workspace. -Require the verified Linux sandbox, read-only workspace/helper runtime access and -restricted network. The helper anchors all traversal to no-follow descriptors and -bounds enumeration before collecting names. Accept only a complete versioned result -after native exit/output close. Account for native output and terminal event -sequence numbers: retained-output eviction or a capped response's `closed` flag -cannot establish completeness. Reject missing/oversized/invalid output; terminate -and confirm exit/output close when rejection precedes settlement. Keep the existing -retained wait and owner failure on native uncertainty; never retry unknown work. -Private directory support alone does not enable a -daemon control operation, public Files route or capability advertisement. +private interface. The Runtime invokes the retained directory helper against the +frozen local workspace. Keep the qualified helper outside writable paths, anchor +traversal to no-follow descriptors, bound enumeration and require a complete +validated result. Helper availability alone does not enable public Files admission. Bound encoded request payloads to 8 KiB and correlation IDs to 128 bytes before admission. Do not echo oversized IDs; omit oversized trace metadata in replies. Bound raw control results to 1 MiB within the existing 4 MiB transport frame; the -native hook's separate 8 MiB bound is unchanged. Neither is a pinned public protocol -limit. Successful reads require complete bytes/truncation and acknowledged native +limits are local policies, not pinned public protocol limits. Successful reads require complete bytes/truncation and acknowledged native close. Safe native rejections carry no bytes; interrupted or ambiguous reads remain unknown and stop further reads on that owner. Local RPC reap never establishes file settlement. Retain a dispatched read's original bounded waiter across observer @@ -1025,91 +871,12 @@ cancellation and resource transfer/release; stop new admission on resource closu The gateway bounds subscriptions and never retries or replays on reconnect. Duplicate pending operation IDs cannot start another read; this control does not promise durable idempotency or result recovery. Preparation/Run ownership, public path authorization, -remote retirement and future Claude placement retain their separate requirements. - -The private [raw Files composition](services/agents-api/tests/native/raw_files/README.md) -reuses the pinned native socket client and the same typed Files/registry fixture. -Record its fixture-only workspace dependency patch separately from the manager -hook and third-party versions. Its finite real Files/history workflow does not -qualify the client's internal unbounded event queue for production. Client closure -is not runner shutdown; join the stock runner before reporting owner teardown. -Bounded native stream checks retain the original whole-file assertions. A truncated -read's asynchronous close and stream EOF are not operation-retirement receipts; -keep production caller-detachment and path-admission qualification separate. -Its dedicated cancellation scenario distinguishes native interruption from command -retirement. Target native background termination only by observed current-Turn -item/process identity, and verify Files plus retained interrupted history through -the maintained native client before any production ownership or public admission. - -The optional Codex file installer runs through the existing native process interface -with bounded stdin chunks, declared length and a SHA-256 commit trailer. It fills -the demonstrated native hard-link overwrite and whole-message size gaps; it is -not a second filesystem service or public admission. Reuse held-directory traversal -and existing rustix directory-relative operations for replacement. Keep preparation, caller -authorization and uncertain mutation recovery in their existing owning layers. -Require an existing disjoint staging directory on the destination filesystem. -The operator must protect that directory and its ancestors from native tool and -background-process writes; same-user mode bits alone do not do so. Use separate -native filesystem policies for the installer and workspace tools, with a dedicated -staging directory per Environment. The qualified installer sees one writable parent -containing only that Environment's workspace and staging; tools retain workspace-only -write access. Keep history, credentials and other tenants outside that parent. -Separate sandbox bind mounts may reject rename even on the same backing filesystem; -never fall back to copying. The helper cannot attest that placement rule; -public admission must establish it. Concurrent workspace writers need not be -globally stopped to protect staged bytes. Temporary-file cleanup is best effort. -A queued stdin receipt, missing helper result or process termination is not a file -commit receipt. See the [installer contract](packages/codex-executor/README.md#scoped-file-installer) -for private limits, cleanup, metadata and concurrency semantics. - -The private harness file socket admits writes only with a frozen operator helper -and staging binding; read-only preparation removes that binding. Workspace and -staging must be distinct siblings under one non-root Environment parent, and the -helper must be outside that writable parent. Receive the complete bounded body -before starting a native process. Transfer 64 KiB chunks plus the digest through -one captured native process; require Linux sandboxing and an exact versioned -commit result with successful exit and complete output closure. Native queued -stdin is not a commit. Caller detach does not cancel admitted work; uncertain -input, output or deadline stops the existing owner without replay or replacement -claims. Public Files.create, trusted placement admission and durable mutation -recovery remain separate work. See the [private transport contract](packages/codex-harness/README.md#private-file-writes). - -The private [retirement qualification](services/agents-api/tests/native/retirement/README.md) -separates native connection/processor shutdown from already admitted filesystem -work. Its hashed test-only scheduling overlay is not a production native patch. -Do not admit a replacement writer based only on socket closure, task cancellation, -command exit or a Core lease change. Require an actual executor mutation-drain or -enforced placement-retirement boundary; retain uncertainty across recovery when -that boundary cannot be established. Native source evidence, instrumented mechanism -tests and uninstrumented real execution remain distinct acceptance claims. -The opt-in whole-placement fixture uses an exact task-owned Docker instance and -cgroup/process observations before successor writes. This is a local-filesystem -qualification, not an authenticated remote retirement receipt or public admission. - -The explicit `parsar-daemon placement enroll/retire` operator commands own the -first local Runtime retirement consumer in `internal/agentdaemon/placement`. -They use a fixed local Docker socket, an exact labeled container/incarnation, -private durable state under `~/.parsar/placements`, and a per-target process lock. -Enrollment is limited to the documented unprivileged Linux/cgroup-v2 local-storage -profile. Keep controller state and the canonical Docker socket outside generated-code mounts, -including when a workspace source is a filesystem root. Every source must reside -on a whole-filesystem host mount whose device appears exactly once in the controller -mount namespace; bind aliases, subvolume roots, stacked mounts and missing mount -evidence are unqualified. This bounded profile does not resolve arbitrary mount graphs. -Stopping, independent membership/process observations and non-forced removal must -precede a durable successful receipt. Recovered receipts must complete their directory-sync barrier before success. -Missing evidence or a crash after removal -but before receipt persistence remains unknown; never clear it based on absence. -Optional `--environment` enrollment freezes one canonical Environment UUID in a -version-2 local receipt. Every scoped retire/reconcile must match it before any -supervisor access or completed-receipt recovery; omission cannot bypass the check. -Version-1 unscoped records remain unscoped and cannot be adopted by a scoped retry. -Older controllers reject version-2 records. Enrollment is trusted operator consent, -not verification of Core resource existence or tenant ownership; the future Core -consumer must validate those using its existing authenticated associations. -Normal harness release is unchanged. This local operator command is not Core -admission, authenticated remote receipt support, or public Files compatibility. -See the retirement fixture README for the profile and explicit native acceptance. +public file authorization and native cleanup retain their separate requirements. + +The retained Rust directory, write and workspace-export helpers provide bounded +filesystem operations for the colocated Runtime. Their protected executable paths, +descriptor-relative traversal and exact workspace binding remain required. They +do not implement an executor transport or grant tenant authority. Connection observations use the existing execution lease and Session lock. A separate `environment_connections` row retains the current generation and revision; @@ -1124,81 +891,16 @@ Turn association. `connected`/`disconnected` are distinct from native preparatio readiness; do not cast resource `expired` into the event vocabulary or emit `ready` for a self-hosted connection. -Registry writes run synchronously outside the relay mutex, with a four-second -operation budget independent of client disconnect. Shutdown closes sockets, shares -one four-second budget across captured disconnects, and drains accepted writes -before the Worker releases its lease. Missing/deleted/terminal targets retire only -the matching connection; other write failures are logged, retained by -`LifecycleError`, and close the registry until restart. No successful persistence -or continuous connectivity is inferred after a failed write. Before starting -connection producers, a new Worker clears old generations and records disconnected -state for old connected observations in batches of 32. Deleted resources stay -hidden. Stop old writers before migrating/deploying this lifecycle; downgrade -refuses to discard retained generation fencing. Metadata reads and full lifecycle conformance remain separate requirements. - -The optional [Codex executor launcher](packages/codex-executor/README.md) is a -separate Cargo package. Pin its native git revisions, transport patches, toolchain -and lock; use the upstream executor/auth/runtime APIs without changing stock CLI -credential protection. It reads only an explicit principal-key credential file with an optional exact-Environment restriction -and uses the matching installed native binary/resources for hidden filesystem and -sandbox helper modes. Keep its state below `~/.parsar/`; do not load ambient -OpenAI login credentials. HTTPS certificate/hostname verification remains enabled. -The separately named command does not establish stock-command compatibility or -enable public Environment admission. Linux x86_64 is its initial deployment target. - -The executor build also provides a small `agents-api-codex-directory` helper for -bounded, descriptor-scoped directory observations where the pinned native walk -cannot maintain path isolation during concurrent ancestor replacement. Use the -existing native process API, explicit argv and a qualified read-only sandbox; -keep the executable at a trusted operator path outside the writable workspace. -The adapter supplies its frozen workspace root. Enumerate and stat using retained -no-follow directory descriptors, bound scanning before collecting all names, and -require complete output plus native exit/close settlement. This is a private -adapter prerequisite, not a new public protocol, transport or filesystem framework. -The helper alone grants no tenant authority, public Files admission, snapshot or -workspace-replacement guarantee. Preserve the stock executor/model loop. - -Native app-server placement is a prerequisite to typed dispatch. The pinned Codex -app-server accepts registry configuration at startup; use explicit native Environment -selections for the first thread and every Turn. Resume does not restore selections -from history. Keep its process cwd and persistent `CODEX_HOME` local, separately -from the executor cwd. Readiness and observed tool/file results are required: -a completed native Turn alone does not establish successful remote execution. -Apply an intentional native shell environment policy; upstream defaults do not -filter all credential variables. Filtering is not process or filesystem isolation. -The opt-in [placement probe](services/agents-api/tests/native/README.md) documents -its real-provider prerequisites and limits. It does not enable public admission. - -The private daemon `remote_environment` descriptor carries Environment identity, -executor workspace and transient native connection URL/token. `WorkDir` and -`CODEX_HOME` remain harness-local. The selected adapter owns the connection -protocol; Codex Noise configuration and native Environment selectors never enter -the API core. Do not persist the connection token in configuration, events or -completion metadata. Existing private provider configuration and device profiles -have separate credential ownership. - -The initial Codex adapter advertises this mode only for the verified 0.153.4 -protocol, propagating the capability through the real heartbeat/gateway. It checks -native remote readiness and absence of local fallback before starting a thread. -Supply a stable state key, strict resume and completion release: each prompt owns -one harness, and a bound Environment permits one harness connection. Send the -native selection on first thread creation and every Turn; cold resume does not -restore it. Reject conflicting native transport settings, unsupported engines or -versions, non-POSIX executor paths, and local managed Skills/MCP/plugins/authoring -or attachments. Apply explicit core shell inheritance and credential exclusions. - -Codex preparation initializes the existing RPC child and verifies environment -readiness without creating a native thread or starting model work. It carries no -RunID or prompt; the existing Factory resolves its state key before using the same -Prepare/Start implementation. The resolved plan, tools and resume identity are -fixed during preparation. Start accepts the actual RunID, prompt and output sink, -checks remote status on the retained RPC without reconnecting, and transfers that -resource once to the normal Session. Failed start or abandoned preparation closes -the child and cleans temporary plan resources; deferred preparation Close is inert -after transfer. The owner context spans the whole harness lifetime, while startup -operation deadlines remain separate. Owner cancellation/RPC exit release pending -resources; executor loss is checked at Start, not continuously monitored. This -adapter seam does not provide public admission or a new scheduler. +The existing Worker observes authenticated daemon peers for enrolled Environments, +using durable generation/revision fencing under its execution lease. On restart it +reconciles old connection observations before admitting new ones. A connection or +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 @@ -1252,8 +954,8 @@ Start is pending; ordinary post-transfer cancellation, complete native output an 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 initial profile -requires a remote environment, stable state key, strict resume and completion +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. @@ -1290,23 +992,12 @@ 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. -This daemon slice keeps existing best-effort cancellation and harness cleanup. -Native detached-session cleanup may stop remote commands after a delay; an applied -receipt is not immediate process quiescence or complete final output/Usage. The -opt-in registered-daemon test independently observes PID exit and stopped heartbeats -while the daemon, registry and executor stay alive. Targeted process termination, -cross-Turn background preservation and complete public cancellation/lifecycle remain -separate work. The public idle-text profile has its own built-service acceptance; -an adapter probe alone does not establish public compatibility. - -The private Dispatcher can execute a pending Environment input on an already bound, -capable daemon. It requires the current leased Store before resolving transient -connection credentials. A typed callback supplies only the URL, token and release; -native registry types remain outside execution code. Derive Environment identity -and workspace from Store ownership. Retain the same physical peer and preparation -handle through readiness, atomic promotion/claim and the first non-replay Start. -Initial prompt/cursor come from the reserved batch and its receipts; later messages -use ordinary steering. Never hold a database lock during native preparation. +The Dispatcher prepares pending Environment input only on its exact enrolled or +managed device. Preserve the same physical peer and preparation handle through +readiness, atomic promotion/claim and the first non-replay Start. Derive workspace +and Environment identity from Store ownership, never caller-selected private paths. +Initial prompt/cursor come from the reserved batch; later messages use ordinary +steering. Never hold a database lock during native preparation. Observe the original pending deadline, cancellation, deletion and peer loss while waiting for readiness. Preparation failure leaves pending input and its deadline @@ -1322,34 +1013,20 @@ Do not fabricate an empty cancellation outcome or infer native quiescence. The connection owner spans preparation and the transferred Run without a reservation-derived Run deadline; every exit releases it. -The existing Worker discovers pending input with a connected, non-revoked device -in the same tenant when its Dispatcher has a connection resolver. An unbound -Session selects a device using the same engine capability checks as ordinary -work, including remote preparation support, then uses the existing immutable -binding before preparation. Existing bindings never move, including when their -device is offline, revoked or lacks a required capability. A missing device or -binding conflict leaves pending input and its deadline intact without a Turn; -other database/ownership errors stop the Worker. Preparation, claim, Run and cleanup occupy one of the same four slots as ordinary Turns, keyed -by Session. Both queues advance bounded ID cursors and alternate candidates; the -pending queue is scanned at most once every five seconds on the existing tick. -A preparation failure may retry while still pending, without extending its stored -deadline. This is private scheduling policy, not an upstream timing guarantee. -Unknown promotion results and errors after admission stop scheduling; existing -claimed-Turn reconciliation handles restart without another Start. Expiry retains -its ordinary cadence even at capacity. Public idle-text admission and principal -identity are implemented; initial inputs and complete public lifecycle remain separate. - -The standalone service wires this resolver when its daemon gateway and -`AGENTS_API_EXECUTOR_URL` are configured. Construct the gateway and native registry, -configure the Dispatcher, then acquire Worker ownership before starting scheduling -or HTTP consumers. Registry construction does not call the ownership callback; -its Worker reference is assigned once before either consumer starts. Invalid -registry configuration therefore fails before acquiring the execution lease. -Shutdown waits for Run cleanup, drains registry observations while the Worker -still owns its lease, then releases execution ownership and the gateway. -The Codex resolver issues a fresh exact-Environment credential for the supplied -execution owner; it does not admit public Environment input or -define a transport for other engines. +The existing Worker scans pending inputs using the same bounded scheduling slots, +Session locks, durable deadlines and engine capability checks. A self-hosted Session +waits for its dedicated enrolled device; it cannot select an arbitrary same-tenant +device or migrate an existing binding. Preparation failure can retry while still +pending without extending the deadline. Unknown promotion results or errors after +admission retain the existing no-replay settlement rules. + +`AGENTS_API_DAEMON_WS_URL` enables the private gateway and supplies the unchanged +public `remote_url`. `AGENTS_API_HARNESSES` explicitly adds deployment-supported +engines to the default engine and configured managed profiles; advertising a +heartbeat alone does not enable an engine. The three native profiles share enrollment +at `/workspace`. Their new user-managed public chain requires fixed-client/raw HTTP, +real-model, recovery, cancellation and credential-lifecycle acceptance separately +from prior Docker or retired remote-executor evidence. #### Independent build artifacts @@ -1531,24 +1208,16 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti the selected harness. Omitted/null service tier currently uses `auto`; complete upstream default/error/retry conformance and remaining MCP/web-search variants remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. -- Service-origin public HTTP MCP uses the native harness client and tool loop. The supported - execution profiles are Codex with `environment:none` or `self_hosted`, and - Claude SDK with `environment:none`. Both require an explicit `service` - connection origin and a trusted service-side harness. The execution device is - part of the service deployment; an arbitrary caller executor cannot be relabeled - service-origin. Admission requires the advertised `mcp_http_tools` capability - during selection and again before claiming work. The `self_hosted` combination - additionally requires `mcp_http_remote_environment` plus existing remote preparation - capabilities, including at the daemon before the factory; individual MCP/remote - capabilities on old peers do not imply the combination. MCP stays in the trusted - service harness while workspace commands use the executor. Static Vault Bearer - authentication additionally requires `mcp_http_remote_bearer_auth`; an older peer - supporting anonymous remote MCP and environment:none authentication separately - cannot execute the authenticated combination. Native remote readiness and exact - MCP preflight both precede - thread creation/resume. Other engines and placements remain implementation gaps. - Claude SDK admission additionally applies the supported values described in - [its adapter profile](#claude-sdk-adapter-foundation), including before persistence. +- Service-origin public HTTP MCP uses the native harness client and tool loop on + trusted service-owned `environment:none` compute, with Codex or Claude SDK. + The V1 colocated `self_hosted` profile rejects it: user-owned compute cannot be + relabeled service-origin or receive its attached Vault credentials. Hosted + service-origin MCP also remains unsupported. Environment-origin Plugin MCP uses + its separate qualified local Runtime transport and isolation contract; do not + disable that path or infer optional-feature equality across engines. + Admission, device selection and preclaim still require the exact supported MCP + capabilities. Native declarations do not widen public placement authorization. + - Keep accepted public MCP credential profiles separate from private adapter capabilities. The execution service declares the verified public bearer profiles centrally; a daemon capability alone cannot open a public profile. Reuse frozen @@ -1591,16 +1260,15 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti - The private Codex adapter's HTTPS MCP bearer authentication requires `mcp_http_bearer_auth` and the existing MCP/environment capabilities, checked before the factory. It is restricted to trusted service-side Codex with - `environment:none` or the explicitly supported authenticated remote combination. A transient + `environment:none`. A transient per-server `bearer_token` becomes a fresh daemon-owned `bearer_token_env_var` reference for each native process. Put the exact secret only in that app-server child's environment, after auxiliary launch probes; never in global environment, arguments, configuration/history, public snapshots or logs. Preflight accepts only the expected server/reference pairing and retains the existing rejection - of ambient credential sources. Remote commands use the existing core-only native - environment policy; service-side bearer variables must not enter executor - environments, commands, files or native history/snapshots. Use the native HTTP - client with TLS verification. + of ambient credential sources. Service-side bearer variables must not enter + generated commands, files or public history/snapshots. Use the native HTTP client + with TLS verification. This execution profile rejects empty values and bytes outside RFC 6750 b64token syntax with generic errors; it never trims tokens or narrows opaque Credential storage. OAuth and hosted redirect/error equivalence @@ -1749,9 +1417,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti creators; only controlled historical fixtures may seed unknown ownership. The operator-selected `AGENTS_API_ENGINE` is separate from the requested model. - Public execution supports Codex and Claude SDK with environment `none`, plus - the Codex self-hosted text/function profile and the qualified Codex/Claude dedicated - Docker hosted profiles; reject unsupported + Public execution supports the enabled `none` profiles, the three colocated + self-hosted profiles and qualified three-harness Docker hosted profiles; reject unsupported input/environment/agent options explicitly. - `packages/agents-client/v1` configures the pinned official `openai-go` Session service. Use SDK request/response types, pagination and errors directly rather @@ -1768,7 +1435,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti `/api/v1/agent-daemon/*`, separately from the official `/v1/agents/*` surface; device credentials grant no Session API or product permissions. The optional `AGENTS_API_DAEMON_WS_URL` enables that gateway. It is a single-process registry, - not a claim of multi-pod execution or the public self-hosted executor protocol. + not a claim of multi-pod execution or stock `exec-server` interoperability. + Self-hosted enrollment uses this gateway with an exact Environment binding. Session/device bindings are tenant-scoped and immutable. Revocation denies new connections and binding reads; an existing connection closes on its next heartbeat. Connectivity comes from the live registry, not a persisted online @@ -1805,8 +1473,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Done is emitted. Preserve separately reported usage on failure; do not add the same counters again when Done also includes them. - The dispatcher is an internal entry point used by the standalone service worker. - Its private `daemon` configuration is neither `environment:none` nor the official - self-hosted executor protocol. Further pending interactions and provider allocation + Legacy internal `daemon` configuration is not a public Environment type. + Self-hosted enrollment uses exact local binding; further pending interactions remain separate slices. Unexpected interaction requests fail explicitly until supported. - `environment_none` advertises an adapter's explicit environment-disable path. Execution snapshots with public `environment.type=none` require that diff --git a/Makefile b/Makefile index b26a16557..f5fa0935e 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-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-harness check-agents-harness check-agents-harness-native 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-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 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-database check-sqlc check-go check-agents-api check-claude-sdk check-mcode-harness check-agents-executor check-agents-harness +check: check-database check-sqlc check-go check-agents-api check-claude-sdk check-mcode-harness check-agents-executor @printf 'Parsar Core checks passed.\n' check-database: @@ -23,7 +23,7 @@ openapi: output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) init \ -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ - --exclude ./services/agents-api/internal/executor --output "$$output" \ + --output "$$output" \ --outputTypes yaml --parseInternal; \ mv "$$output/swagger.yaml" contracts/agents-api/openapi.yaml @@ -76,15 +76,6 @@ build-agents-executor: check-agents-executor: ./scripts/check-agents-executor.sh -build-agents-harness: - ./scripts/build-agents-harness.sh - -check-agents-harness: - ./scripts/check-agents-harness.sh - -check-agents-harness-native: - ./scripts/build-agents-harness.sh check - build-agents-runtime: ./scripts/build-agents-runtime.sh diff --git a/README.md b/README.md index 7b3f98bb4..23eff57e1 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,19 @@ Standalone Agent API Core and its execution runtimes, copied from The source repository retains both its product and its existing Core copy. This repository contains the API service, PostgreSQL migrations, pinned public -protocol, execution daemon, Docker/E2B providers, native Harness adapters, +protocol, execution daemon, the Docker provider, native Harness adapters, runtime image builders, client library, tests and operator documentation. It does not contain the Parsar web application, product backend, product database, business CLI or product deployment stack. +V1 user-managed deployments colocate our daemon, selected harness, tools and +`/workspace`. Core manages Docker only; users provision, renew and destroy E2B +through the official SDK. The returned `remote_url` uses our private daemon +transport, not stock `exec-server`. See the +[Runtime enrollment guide](services/agents-api/README.md#user-managed-runtime-enrollment) +for harness enablement and the [qualification record](contracts/agents-api/user-managed-runtime-v1.md) +for tested deployments and remaining limits. + ## Start here - [API setup, authentication and execution](services/agents-api/README.md) @@ -50,5 +58,7 @@ make check 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 [native tests](services/agents-api/tests/native/README.md). +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. diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go index 5c77f2fcd..e3bb3235e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go @@ -21,7 +21,7 @@ func validateMCP(req proto.PromptRequestPayload) error { if req.MCPHTTPServers == nil { return nil } - if !req.DisableExecutionEnvironment || req.RemoteEnvironment != nil { + if !req.DisableExecutionEnvironment { return fmt.Errorf("claudesdk: HTTP MCP requires environment:none") } return validateMCPServers(*req.MCPHTTPServers) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 241a6dfc0..3a58c38b2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -45,7 +45,7 @@ type workspaceProfile struct { } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.RemoteEnvironment != nil || req.MCPHTTPServers != nil { + if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index 5f6e9338c..419f0af7a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -76,15 +76,13 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "remote", "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", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() switch name { case "none": req.DisableExecutionEnvironment = true - case "remote": - req.RemoteEnvironment = &proto.RemoteEnvironment{} case "work-dir": req.WorkDir = config.Workspace.ScratchDir case "mcp": diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go index a459db36b..2eace2ad1 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment.go +++ b/apps/parsar-daemon/internal/agent/codex/environment.go @@ -3,7 +3,12 @@ package codex import ( "context" "encoding/json" + "errors" "fmt" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) // Check the native provider instead of assuming an older binary honors the flag. @@ -33,3 +38,31 @@ 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. +func validateNativeTransportEnvironment(req proto.PromptRequestPayload) error { + options, err := buildSessionEnv(req.AgentOptions) + if err != nil { + return err + } + for _, environment := range [][]string{os.Environ(), options} { + for _, entry := range environment { + key, value, _ := strings.Cut(entry, "=") + if value == "" { + continue + } + if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { + return errors.New("codex: retired executor transport configuration is not supported") + } + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go index 415106ed3..c9b078e66 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -9,7 +9,7 @@ import ( // SupportsLocalEnvironment checks deployment prerequisites, not public admission. func SupportsLocalEnvironment(version string) bool { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsRemoteEnvironment(version) || os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("PARSAR_CODEX_HARNESS_BIN") != "" { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsNativeSessionRecovery(version) || os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("PARSAR_CODEX_HARNESS_BIN") != "" { return false } binding, err := localworkspace.Load() diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote.go b/apps/parsar-daemon/internal/agent/codex/environment_remote.go deleted file mode 100644 index aef9b1264..000000000 --- a/apps/parsar-daemon/internal/agent/codex/environment_remote.go +++ /dev/null @@ -1,71 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// SupportsRemoteEnvironment admits the native protocol verified by the placement -// and daemon probes. Each binding still needs a native connection/readiness check. -func SupportsRemoteEnvironment(version string) bool { - return strings.TrimSpace(version) == "codex-cli 0.153.4" -} - -// EnvironmentSelection mirrors the native app-server selection. Resume does not -// retain this selection; send it with every turn/start, including cold resumes. -type EnvironmentSelection struct { - EnvironmentID string `json:"environmentId"` - Cwd string `json:"cwd"` - RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` -} - -func configureRemoteEnvironment(plan *SessionPlan, environment proto.RemoteEnvironment) { - plan.Env = append(plan.Env, - "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL="+strings.TrimRight(environment.ConnectionURL, "/"), - "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID="+environment.ID, - "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN="+environment.ConnectionToken) - plan.Environments = []EnvironmentSelection{{ - EnvironmentID: "remote", Cwd: environment.WorkspaceDirectory, - RuntimeWorkspaceRoots: []string{environment.WorkspaceDirectory}, - }} - configureRestrictedShellEnvironment(plan) -} - -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"}) -} - -func verifyRemoteEnvironment(parent context.Context, rpc *JSONRPCClient) error { - ctx, cancel := context.WithTimeout(parent, 30*time.Second) - defer cancel() - status, err := nativeEnvironmentStatus(ctx, rpc, "local") - if err != nil || status != "unknown" { - return errors.New("codex: cannot confirm absence of local execution fallback") - } - // environment/info establishes the native encrypted connection. Status alone - // observes a lazy pending environment without connecting or recovering it. - raw, err := rpc.Request(ctx, "environment/info", map[string]string{"environmentId": "remote"}) - if err != nil { - return errors.New("codex: remote environment connection failed") - } - var info struct { - Shell struct { - Path string `json:"path"` - } `json:"shell"` - } - if json.Unmarshal(raw, &info) != nil || info.Shell.Path == "" { - return errors.New("codex: invalid remote environment information") - } - status, err = nativeEnvironmentStatus(ctx, rpc, "remote") - if err != nil || status != "ready" { - return errors.New("codex: remote environment is not ready") - } - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go deleted file mode 100644 index c8dd64910..000000000 --- a/apps/parsar-daemon/internal/agent/codex/environment_remote_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestRemoteEnvironmentRequiresNativeReadiness(t *testing.T) { - for _, mode := range []string{"ready", "local fallback", "connection rejected", "invalid info", "pending", "disconnected"} { - t.Run(mode, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - done := make(chan error, 1) - go func() { done <- verifyRemoteEnvironment(ctx, client.JSONRPCClient) }() - for index, method := range []string{"environment/status", "environment/info", "environment/status"} { - var request struct { - ID, Method string - Params map[string]string - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - id := "remote" - if index == 0 { - id = "local" - } - if request.Method != method || request.Params["environmentId"] != id { - t.Fatal(request) - } - var result any = map[string]string{"status": "unknown"} - stop := false - if index == 0 && mode == "local fallback" { - result = map[string]string{"status": "ready"} - stop = true - } - if index == 1 { - result = map[string]any{"shell": map[string]string{"path": "/bin/bash"}} - } - if index == 1 && mode == "invalid info" { - result = map[string]any{} - stop = true - } - if index == 2 { - status := "ready" - if mode == "pending" || mode == "disconnected" { - status = mode - } - result = map[string]string{"status": status} - } - reply := map[string]any{"id": request.ID, "result": result} - if index == 1 && mode == "connection rejected" { - delete(reply, "result") - reply["error"] = map[string]any{"code": -32603, "message": "connection denied"} - stop = true - } - if err := json.NewEncoder(server.ToClient).Encode(reply); err != nil { - t.Fatal(err) - } - if stop { - break - } - } - if err := <-done; (err == nil) != (mode == "ready") { - t.Fatalf("%s: %v", mode, err) - } - }) - } -} - -func TestRemoteEnvironmentSelectedForFirstAndResumedTurns(t *testing.T) { - for _, resume := range []bool{false, true} { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - s := &Session{rpc: client.JSONRPCClient, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), out: make(chan proto.Envelope, 8), bufs: NewItemBuffers(), waitDone: make(chan struct{}), cleanup: func() {}, interactions: newPendingCodexInteractions()} - plan := SessionPlan{Cwd: "/local-harness", Environments: []EnvironmentSelection{{EnvironmentID: "remote", Cwd: "/executor-only", RuntimeWorkspaceRoots: []string{"/executor-only"}}}} - req := proto.PromptRequestPayload{Prompt: "remote work", StrictResume: true} - method := "thread/start" - if resume { - req.AgentSessionID = "native-thread" - method = "thread/resume" - } - go s.run(plan, req) - for index, expected := range []string{method, "turn/start"} { - var request struct { - ID, Method string - Params struct { - Environments []EnvironmentSelection `json:"environments"` - Cwd string `json:"cwd"` - ThreadID string `json:"threadId"` - } - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != expected { - t.Fatal(request.Method) - } - if index == 1 || !resume { - if len(request.Params.Environments) != 1 || request.Params.Environments[0].EnvironmentID != "remote" || request.Params.Environments[0].Cwd != "/executor-only" { - t.Fatal("native request omitted remote selection") - } - } else if len(request.Params.Environments) != 0 { - t.Fatal("resume invented unsupported environments field") - } - if index == 0 && !resume && request.Params.Cwd != "/local-harness" { - t.Fatal("thread/start changed local cwd") - } - if index == 1 && request.Params.ThreadID != "native-thread" { - t.Fatal("turn lost native identity") - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"thread": map[string]string{"id": "native-thread"}}}); err != nil { - t.Fatal(err) - } - } - cancel() - select { - case <-s.waitDone: - case <-time.After(time.Second): - t.Fatal("native run did not stop") - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go deleted file mode 100644 index 836922d52..000000000 --- a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation.go +++ /dev/null @@ -1,94 +0,0 @@ -package codex - -import ( - "errors" - "net" - "net/url" - "os" - "path" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func validateRemoteEnvironmentRequest(req proto.PromptRequestPayload) error { - environment := req.RemoteEnvironment - if environment == nil { - return nil - } - if req.DisableExecutionEnvironment { - return errors.New("codex: remote environment conflicts with environment none") - } - if !req.ReleaseOnCompletion || !req.StrictResume || strings.TrimSpace(req.AgentStateKey) == "" { - return errors.New("codex: remote environment requires completion release, strict resume and a stable state key") - } - if req.WorkspaceAuthoring || len(req.Attachments) != 0 { - return errors.New("codex: remote authoring and attachments are not supported") - } - for _, key := range []string{"skills", "mcp_servers", "plugin_dirs"} { - if hasLocalEnvironmentOption(req.AgentOptions[key]) { - return errors.New("codex: remote environment does not support local managed skills, MCP or plugins") - } - } - if environment.ID == "" || environment.ID == "." || environment.ID == ".." || url.PathEscape(environment.ID) != environment.ID { - return errors.New("codex: remote environment requires a valid identity") - } - if !path.IsAbs(environment.WorkspaceDirectory) || strings.ContainsAny(environment.WorkspaceDirectory, "\x00\r\n\\") { - return errors.New("codex: remote workspace must be an absolute POSIX path") - } - if strings.TrimSpace(environment.ConnectionToken) == "" || strings.ContainsAny(environment.ConnectionToken, "\x00\r\n") { - return errors.New("codex: remote environment requires a valid connection credential") - } - u, err := url.Parse(environment.ConnectionURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { - return errors.New("codex: remote connection URL must be an absolute origin without credentials") - } - switch u.Scheme { - case "https": - case "http": - ip := net.ParseIP(u.Hostname()) - if !strings.EqualFold(u.Hostname(), "localhost") && (ip == nil || !ip.IsLoopback()) { - return errors.New("codex: remote HTTP connections require loopback") - } - default: - return errors.New("codex: remote connection requires HTTPS or loopback HTTP") - } - for _, entry := range os.Environ() { - key, value, _ := strings.Cut(entry, "=") - if reservedRemoteEnvironmentVariable(key) && value != "" { - return errors.New("codex: remote environment conflicts with native transport process configuration") - } - } - env, err := buildSessionEnv(req.AgentOptions) - if err != nil { - return err - } - for _, entry := range env { - key, _, _ := strings.Cut(entry, "=") - if reservedRemoteEnvironmentVariable(key) { - return errors.New("codex: remote environment conflicts with native transport agent options") - } - } - return nil -} - -func reservedRemoteEnvironmentVariable(key string) bool { - return strings.HasPrefix(strings.ToUpper(key), "CODEX_EXEC_SERVER_") -} - -func hasLocalEnvironmentOption(value any) bool { - switch v := value.(type) { - case nil: - return false - case []any: - return len(v) != 0 - case []string: - return len(v) != 0 - case map[string]any: - return len(v) != 0 - case map[string]string: - return len(v) != 0 - default: - return true - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go deleted file mode 100644 index 0c85f11a0..000000000 --- a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func remoteEnvironmentRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "remote-test", ReleaseOnCompletion: true, StrictResume: true, - RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment-test", WorkspaceDirectory: "/executor-only", - ConnectionURL: "https://registry.example", ConnectionToken: "synthetic-harness-token"}} -} - -func TestRemoteEnvironmentValidatesBeforeLocalSetup(t *testing.T) { - cases := map[string]func(*proto.PromptRequestPayload){ - "none conflict": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, - "retained harness": func(r *proto.PromptRequestPayload) { r.ReleaseOnCompletion = false }, - "silent new thread": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, - "missing state": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "" }, - "authoring": func(r *proto.PromptRequestPayload) { r.WorkspaceAuthoring = true }, - "skills": func(r *proto.PromptRequestPayload) { r.AgentOptions = map[string]any{"skills": []any{"local"}} }, - "MCP": func(r *proto.PromptRequestPayload) { - r.AgentOptions = map[string]any{"mcp_servers": map[string]any{"local": map[string]any{}}} - }, - "plugins": func(r *proto.PromptRequestPayload) { - r.AgentOptions = map[string]any{"plugin_dirs": []string{"/local"}} - }, - "identity": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ID = "../another" }, - "relative workspace": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.WorkspaceDirectory = "relative" }, - "home workspace": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.WorkspaceDirectory = "~/remote" }, - "URL credentials": func(r *proto.PromptRequestPayload) { - r.RemoteEnvironment.ConnectionURL = "https://private:secret@registry.example" - }, - "URL query": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionURL += "?token=secret" }, - "plaintext remote": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionURL = "http://registry.example" }, - "missing token": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionToken = "" }, - "invalid token": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment.ConnectionToken += "\n" }, - "transport options": func(r *proto.PromptRequestPayload) { - r.AgentOptions = map[string]any{"env": map[string]any{"CODEX_EXEC_SERVER_URL": "none"}} - }, - } - for name, change := range cases { - t.Run(name, func(t *testing.T) { - r := remoteEnvironmentRequest() - r.WorkDir = filepath.Join(t.TempDir(), "must-not-be-created") - change(&r) - _, err := newSession(context.Background(), r, make(chan proto.Envelope, 8), defaultSessionConfig()) - if err == nil || strings.Contains(err.Error(), "synthetic-harness-token") || strings.Contains(err.Error(), "private:secret") { - t.Fatalf("invalid or unsafe rejection: %v", err) - } - if _, err := os.Stat(r.WorkDir); !os.IsNotExist(err) { - t.Fatal("invalid binding reached local setup") - } - }) - } -} - -func TestRemoteEnvironmentRejectsAmbientTransport(t *testing.T) { - t.Setenv("CODEX_EXEC_SERVER_URL", "none") - if err := validateRemoteEnvironmentRequest(remoteEnvironmentRequest()); err == nil { - t.Fatal("ambient native transport accepted") - } -} - -func TestRemoteEnvironmentKeepsPathsAndCredentialsSeparate(t *testing.T) { - home := t.TempDir() - t.Setenv("PARSAR_HOME", home) - r := remoteEnvironmentRequest() - r.WorkDir = filepath.Join(home, "harness") - r.RemoteEnvironment.ConnectionURL = "http://127.0.0.1:34567/" - r.RemoteEnvironment.WorkspaceDirectory = "/remote-environment-" + filepath.Base(home) - r.AgentOptions = map[string]any{"skills": []any{}, "mcp_servers": map[string]any{}} - if err := validateRemoteEnvironmentRequest(r); err != nil { - t.Fatal(err) - } - plan, skillRoot, err := prepareSessionPlan(t.Context(), r, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if plan.Cwd != r.WorkDir || len(skillRoot) != 0 || len(plan.Environments) != 1 || plan.Environments[0].Cwd != r.RemoteEnvironment.WorkspaceDirectory || plan.Environments[0].EnvironmentID != "remote" { - t.Fatal("remote execution changed harness cwd or installed local skills") - } - if _, err := os.Stat(r.RemoteEnvironment.WorkspaceDirectory); !os.IsNotExist(err) { - t.Fatal("remote workspace exists on the harness host") - } - env := map[string]string{} - for _, entry := range plan.Env { - key, value, _ := strings.Cut(entry, "=") - env[key] = value - } - if env["CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"] != r.RemoteEnvironment.ConnectionToken || env["CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"] != r.RemoteEnvironment.ID || env["CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"] != "http://127.0.0.1:34567" { - t.Fatal("native launch did not consume the transient connection") - } - config := map[string]string{} - for _, pair := range plan.ExtraConfig { - config[pair[0]] = pair[1] - } - if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { - t.Fatal("credential inheritance policy is not explicit") - } - if err := filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - data, err := os.ReadFile(path) - if strings.Contains(string(data), r.RemoteEnvironment.ConnectionToken) { - t.Errorf("connection credential persisted in %s", path) - } - return err - }); err != nil { - t.Fatal(err) - } - wire, err := json.Marshal(plan.Environments) - if err != nil || strings.Contains(string(wire), r.RemoteEnvironment.ConnectionToken) { - t.Fatal("secret in native selection") - } -} - -func TestRemoteEnvironmentCapabilityRequiresVerifiedVersion(t *testing.T) { - for _, version := range []string{"", "codex-cli 0.141.0", "codex-cli 0.153.4", "codex-cli 0.154.0", "some binary"} { - if SupportsRemoteEnvironment(version) != (version == "codex-cli 0.153.4") { - t.Fatalf("unexpected capability for %q", version) - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_retired_test.go b/apps/parsar-daemon/internal/agent/codex/environment_retired_test.go new file mode 100644 index 000000000..11c4f0551 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/environment_retired_test.go @@ -0,0 +1,77 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.WorkspaceReadOnly = true + prepared, err := newPreparation(t.Context(), req, cfg) + if err == nil || prepared != nil { + t.Fatal("read-only request admitted", err) + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("read-only request started native child") + } + if _, err := os.Stat(filepath.Join(root, "parsar-daemon", "agent-sessions")); !os.IsNotExist(err) { + t.Fatal("read-only request created native state", err) + } +} + +func TestRetiredNativeTransportOptionsRejectedBeforeState(t *testing.T) { + for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { + for _, source := range []string{"process", "options"} { + for _, none := range []bool{false, true} { + t.Run(key+"/"+source+"/"+map[bool]string{false: "local", true: "none"}[none], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.DisableExecutionEnvironment = none + if !none { + req.LocalEnvironment = &proto.LocalEnvironment{ID: "local"} + } + if source == "process" { + t.Setenv(key, "retired-private-value") + } else { + req.AgentOptions["env"] = map[string]any{key: "retired-private-value"} + } + p, err := newPreparation(t.Context(), req, cfg) + if p != nil || err == nil || !strings.Contains(err.Error(), "retired executor transport") || strings.Contains(err.Error(), "retired-private-value") { + t.Fatal("transport override admitted or disclosed", err) + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("retired transport started native process") + } + if _, err := os.Stat(filepath.Join(root, "parsar-daemon", "agent-sessions")); !os.IsNotExist(err) { + t.Fatal("retired transport created state", err) + } + }) + } + } + } +} + +func TestNativeNoneSelectorRemainsSupported(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("CODEX_EXEC_SERVER_URL", "none") + req.AgentOptions["env"] = map[string]any{"CODEX_EXEC_SERVER_URL": "none"} + p, err := newPreparation(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + defer p.Close() + assertPreparationOnly(t, root) + statuses := 0 + for _, frame := range preparationFrames(t, root) { + if frame.Method == "environment/status" { + statuses++ + } + } + if statuses != 2 { + t.Fatal("none did not verify both native execution environments") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http.go b/apps/parsar-daemon/internal/agent/codex/mcp_http.go index 69098b3ec..86daead44 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http.go @@ -18,8 +18,8 @@ func publicMCPHTTPServers(req proto.PromptRequestPayload) (map[string]mcpServerC if req.MCPHTTPServers == nil { return nil, nil } - if req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { - return nil, errors.New("codex: public HTTP MCP requires environment:none or a remote environment") + if !req.DisableExecutionEnvironment { + return nil, errors.New("codex: public HTTP MCP requires environment:none") } servers := make(map[string]mcpServerConfig, len(*req.MCPHTTPServers)) for _, declaration := range *req.MCPHTTPServers { diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go index 22848f853..05ef9965a 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -12,51 +12,43 @@ import ( ) func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { - for _, remote := range []bool{false, true} { - t.Run(map[bool]string{false: "none", true: "remote"}[remote], func(t *testing.T) { - t.Setenv("PARSAR_HOME", t.TempDir()) - tokens := []string{"first-synthetic.token+/==", "second-synthetic_token~"} - servers := []proto.MCPHTTPServer{ - {ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, - {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, - {ServerLabel: "public", ServerURL: "http://public.example/mcp"}, + t.Setenv("PARSAR_HOME", t.TempDir()) + tokens := []string{"first-synthetic.token+/==", "second-synthetic_token~"} + servers := []proto.MCPHTTPServer{ + {ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, + {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, + {ServerLabel: "public", ServerURL: "http://public.example/mcp"}, + } + req := proto.PromptRequestPayload{AgentStateKey: "retained-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} + seen := map[string]bool{} + for range 2 { + plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + config, err := os.ReadFile(filepath.Join(plan.Cwd, "config.toml")) + if err != nil { + t.Fatal(err) + } + args, _ := json.Marshal(plan.ExtraConfig) + for i, server := range servers[:2] { + ref := plan.mcpServers[server.ServerLabel].BearerTokenEnvVar + if !strings.HasPrefix(ref, "PARSAR_MCP_BEARER_") || seen[ref] || !slices.Contains(plan.Env, ref+"="+tokens[i]) { + t.Fatal("missing exact per-server secret or reused native reference") } - req := proto.PromptRequestPayload{AgentStateKey: "retained-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} - if remote { - req = remoteEnvironmentRequest() - req.MCPHTTPServers = &servers + seen[ref] = true + if _, present := os.LookupEnv(ref); present { + t.Fatal("secret entered parent environment") } - seen := map[string]bool{} - for range 2 { - plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - config, err := os.ReadFile(filepath.Join(plan.Cwd, "config.toml")) - if err != nil { - t.Fatal(err) - } - args, _ := json.Marshal(plan.ExtraConfig) - for i, server := range servers[:2] { - ref := plan.mcpServers[server.ServerLabel].BearerTokenEnvVar - if !strings.HasPrefix(ref, "PARSAR_MCP_BEARER_") || seen[ref] || !slices.Contains(plan.Env, ref+"="+tokens[i]) { - t.Fatal("missing exact per-server secret or reused native reference") - } - seen[ref] = true - if _, present := os.LookupEnv(ref); present { - t.Fatal("secret entered parent environment") - } - if !strings.Contains(string(config), `bearer_token_env_var = "`+ref+`"`) || strings.Contains(string(config), tokens[i]) || strings.Contains(string(args), tokens[i]) { - t.Fatal("secret reached configuration/arguments or reference was omitted") - } - } - if plan.mcpServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { - t.Fatal("credential-free server received authentication") - } - plan.Cleanup() + if !strings.Contains(string(config), `bearer_token_env_var = "`+ref+`"`) || strings.Contains(string(config), tokens[i]) || strings.Contains(string(args), tokens[i]) { + t.Fatal("secret reached configuration/arguments or reference was omitted") } - }) + } + if plan.mcpServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { + t.Fatal("credential-free server received authentication") + } + plan.Cleanup() } } diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go index 012e84451..ce2045d43 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -124,15 +124,9 @@ func TestPublicMCPHTTPPreflightRedactsNativeErrors(t *testing.T) { } func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { - for _, mode := range []string{"new", "resume", "reject", "reject bearer reference", "remote new", "remote resume", "remote reject"} { + for _, mode := range []string{"new", "resume", "reject", "reject bearer reference"} { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) - remote := strings.HasPrefix(mode, "remote ") - mode = strings.TrimPrefix(mode, "remote ") - if !remote { - req.RemoteEnvironment = nil - req.DisableExecutionEnvironment = true - } req.AgentOptions = map[string]any{"model": "fixture-model"} servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} if mode == "reject bearer reference" { @@ -143,9 +137,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { if mode == "resume" { req.AgentSessionID = "fixture-native-thread" } - if !remote { - t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "unknown-status")) - } + t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "unknown-status")) if err := os.WriteFile(filepath.Join(root, "unknown-status"), []byte("unknown"), 0o600); err != nil { t.Fatal(err) } @@ -183,14 +175,14 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { } defer s.Cancel(context.Background()) frames := waitPreparationMethod(t, root, "turn/start") - checked, ready := false, !remote + checked, statuses := false, 0 for _, frame := range frames { - if frame.Method == "environment/info" { - ready = true + if frame.Method == "environment/status" { + statuses++ } if frame.Method == "config/read" { - if !ready { - t.Fatal("MCP check preceded remote readiness") + if statuses != 2 { + t.Fatal("MCP check preceded disabled-environment confirmation") } checked = true } diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go index 8ba1b4de7..fabf83f73 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go @@ -84,7 +84,6 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { valid := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} for _, req := range []proto.PromptRequestPayload{ {MCPHTTPServers: &valid}, - {MCPHTTPServers: &valid, DisableExecutionEnvironment: true, RemoteEnvironment: &proto.RemoteEnvironment{ID: "remote"}}, } { if _, err := publicMCPHTTPServers(req); err == nil { t.Fatal("non-service profile accepted") @@ -147,8 +146,8 @@ func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { } } -func TestRemoteMCPBearerRequiresHTTPS(t *testing.T) { - req := remoteEnvironmentRequest() +func TestPublicMCPBearerRequiresHTTPS(t *testing.T) { + req := proto.PromptRequestPayload{DisableExecutionEnvironment: true} token := "synthetic-private-token" servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} req.MCPHTTPServers = &servers @@ -157,6 +156,6 @@ func TestRemoteMCPBearerRequiresHTTPS(t *testing.T) { } servers[0].ServerURL = "https://tools.example/mcp" if _, err := publicMCPHTTPServers(req); err != nil { - t.Fatal("remote HTTPS bearer declaration rejected", err) + t.Fatal("HTTPS bearer declaration rejected", err) } } diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 2a8408a22..b30d320de 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -21,9 +21,6 @@ type SessionPlan struct { // the spawned app-server). Empty when the caller provided no work_dir. Cwd string - // Environments select native execution independently of the process cwd. - Environments []EnvironmentSelection - // Env is the full environment slice (KEY=value) to layer onto // os.Environ() before spawning. Includes CODEX_HOME, plus any // caller-provided OPENAI_API_KEY / CODEX_API_KEY / proxy vars. diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go index 2d0c265d5..953442a22 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -21,7 +21,7 @@ func validatePermissionProfile(req proto.PromptRequestPayload, profile string) e if strings.TrimSpace(profile) != profile || strings.HasPrefix(profile, ":") { return errors.New("codex: deployment permissions require a named native profile") } - if req.RemoteEnvironment != nil || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { + if req.DisableExecutionEnvironment || req.WorkspaceReadOnly { return errors.New("codex: deployment permission profile requires local execution") } return nil @@ -35,7 +35,7 @@ func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) 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" || cfg.harnessBinary != "" { + 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 { 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 289180d70..e1c9709f7 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -18,7 +18,6 @@ func TestPermissionProfileRejectsIncompatiblePreparationBeforeState(t *testing.T }{ {"builtin", ":danger-full-access", proto.PromptRequestPayload{}}, {"whitespace", " ", proto.PromptRequestPayload{}}, - {"remote", "managed-workspace", proto.PromptRequestPayload{RemoteEnvironment: &proto.RemoteEnvironment{}}}, {"none", "managed-workspace", proto.PromptRequestPayload{DisableExecutionEnvironment: true}}, {"read-owner", "managed-workspace", proto.PromptRequestPayload{WorkspaceReadOnly: true}}, } { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index eb25bee4e..fc4c36bb5 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -36,8 +36,8 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan } func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Prepared, error) { - if req.WorkspaceReadOnly && (!proto.ValidWorkspaceReadPreparation(req) || cfg.harnessBinary == "") { - return nil, errors.New("codex: read-only preparation requires a private harness and a closed read configuration") + if req.WorkspaceReadOnly { + return nil, errors.New("codex: workspace reads use the local Runtime interface") } if req.RequireExistingNativeSession && (!req.StrictResume || req.AgentStateKey == "" || req.WorkspaceReadOnly) { return nil, errors.New("codex: native-session recovery requires strict private state") @@ -58,14 +58,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if err != nil { return nil, err } - if err := validateRemoteEnvironmentRequest(req); err != nil { - return nil, err - } req.AgentStateKey = effectiveAgentStateKey(req) - if !req.WorkspaceReadOnly { - req.AgentOptions = executionOptions(req) - } + req.AgentOptions = executionOptions(req) plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) if err != nil { return nil, err @@ -82,20 +77,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg LogTag: "codex-preparation", Logger: cfg.logger, } - if req.WorkspaceReadOnly { - rpcCfg.Env = append(workspaceReadEnvironment(os.Environ()), plan.Env...) - rpcCfg.ExtraArgs = []string{"--workspace-read-only"} - } for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } - harness, err := configurePrivateHarness(&rpcCfg, cfg.harnessBinary, req.RemoteEnvironment) - if err != nil { - cancelFn() - plan.Cleanup() - return nil, err - } - if err := configureManagedNetworkProcess(&rpcCfg, plan.managedRequirements); err != nil { cancelFn() plan.Cleanup() @@ -103,7 +87,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } rpc := NewJSONRPCClient(rpcCfg) - defer harness.releaseWith(rpc) s := &Session{ toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, @@ -114,7 +97,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, - harness: harness, cancelCtx: cancelCtx, cancelFn: cancelFn, waitDone: make(chan struct{}), @@ -123,12 +105,9 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg resolvedModel: plan.Model, interactions: newPendingCodexInteractions(), } - if req.WorkspaceReadOnly { - s.cleanup = readPreparationCleanup(rpc, s.cleanup) - } plan.Cleanup = s.cleanup p := &Prepared{ - session: s, plan: plan, remote: req.RemoteEnvironment != nil, workspaceReadOnly: req.WorkspaceReadOnly, + session: s, plan: plan, resumeID: req.AgentSessionID, strictResume: req.StrictResume, requireExistingNativeSession: req.RequireExistingNativeSession, transferred: make(chan struct{}), } @@ -140,9 +119,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if _, err := rpc.Start(cancelCtx, initParams); err != nil { return p.preparationFailed(fmt.Errorf("codex: rpc start: %w", err)) } - if err := harness.verify(); err != nil { - return p.preparationFailed(err) - } if req.DisableExecutionEnvironment { if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { cancelFn() @@ -151,11 +127,6 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return nil, err } } - if req.RemoteEnvironment != nil { - if err := verifyRemoteEnvironment(cancelCtx, rpc); err != nil { - return p.preparationFailed(err) - } - } if s.toolEnvironment { if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { return p.preparationFailed(err) @@ -181,3 +152,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg go p.watchOwner() return p, nil } + +func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { + _ = p.Close() + 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 8ca9fa19d..3dcb9aba3 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -25,7 +25,7 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig t.Setenv("PARSAR_HOME", root) t.Setenv("PARSAR_PREPARATION_FAKE", "1") t.Setenv("PARSAR_PREPARATION_FRAMES", filepath.Join(root, "frames.jsonl")) - t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "remote-status")) + t.Setenv("PARSAR_PREPARATION_STATUS", filepath.Join(root, "environment-status")) t.Setenv("PARSAR_PREPARATION_BLOCK", "") t.Setenv("PARSAR_PREPARATION_OBSERVE", "") for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { @@ -42,9 +42,9 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "prepared-session", WorkDir: filepath.Join(root, "harness"), ReleaseOnCompletion: true, StrictResume: true, - AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}, - RemoteEnvironment: &proto.RemoteEnvironment{ID: "fixture-environment", WorkspaceDirectory: "/executor-only", ConnectionURL: "http://127.0.0.1:12345", ConnectionToken: "synthetic-harness-token"}, - FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, + AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}, + DisableExecutionEnvironment: true, + FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"integer"}}}`)}}, } return req, cfg, root } @@ -99,7 +99,7 @@ func assertPreparationOnly(t *testing.T, root string) { func preparedCatalogs(t *testing.T, root string) []string { t.Helper() - files, err := filepath.Glob(filepath.Join(root, "parsar-daemon", "agent-sessions", "prepared-session", "model-catalog-*.json")) + files, err := filepath.Glob(filepath.Join(root, "parsar-daemon", "agent-sessions", "*", "model-catalog-*.json")) if err != nil { t.Fatal(err) } @@ -132,7 +132,6 @@ func TestPreparationFakeCodexProcess(t *testing.T) { os.Exit(0) } } - fakePrivateHarnessEndpoint() log, err := os.OpenFile(os.Getenv("PARSAR_PREPARATION_FRAMES"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { os.Exit(2) @@ -153,27 +152,15 @@ func TestPreparationFakeCodexProcess(t *testing.T) { switch frame.Method { case "initialize": result = map[string]string{"userAgent": "fixture-codex"} - case "environment/info": + case "environment/status": if os.Getenv("PARSAR_PREPARATION_BLOCK") == "1" { for { time.Sleep(time.Second) } } - result = map[string]any{"shell": map[string]string{"path": "/bin/sh"}} - case "environment/status": - var params map[string]string - _ = json.Unmarshal(frame.Params, ¶ms) status := "unknown" - if params["environmentId"] == "remote" { - status = "ready" - if data, err := os.ReadFile(os.Getenv("PARSAR_PREPARATION_STATUS")); err == nil { - status = string(data) - } - } - if status == "blocked" { - for { - time.Sleep(time.Second) - } + if data, err := os.ReadFile(os.Getenv("PARSAR_PREPARATION_STATUS")); err == nil { + status = string(data) } result = map[string]string{"status": status} case "config/read": 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 80bd7b5d6..3e6b3d607 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -3,6 +3,10 @@ package codex import ( "context" "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/google/uuid" + "os" "testing" "time" @@ -26,8 +30,31 @@ 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) + 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{ + "PARSAR_RUNTIME_ENVIRONMENT_ID": environment, + "PARSAR_RUNTIME_SESSION_ID": session, + "PARSAR_RUNTIME_WORKSPACE": req.WorkDir, + "PARSAR_RUNTIME_DIRECTORY_HELPER": cfg.codexBinary, + "PARSAR_RUNTIME_NETWORK_ACCESS": "enabled", + } { + t.Setenv(key, value) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + 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"} registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + 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) @@ -40,7 +67,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { return p, nil }) sender := make(preparationWireSender, 64) - r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) + r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, LocalWorkspace: binding}) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/parsar-daemon/internal/agent/codex/prepared.go index 2f0220db9..79707cd54 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared.go @@ -3,10 +3,8 @@ package codex import ( "context" "errors" - "os" "strings" "sync" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -14,13 +12,10 @@ import ( // Prepared owns a connected native resource until Start transfers it to a Session. // It observes owner cancellation and RPC exit, not continuous executor readiness. -// Remote status is rechecked at Start without reconnecting the prepared resource. type Prepared struct { mu sync.Mutex session *Session plan SessionPlan - remote bool - workspaceReadOnly bool resumeID string strictResume bool requireExistingNativeSession bool @@ -44,9 +39,6 @@ func (p *Prepared) Start(ctx context.Context, runID, prompt string, out chan<- p } func (p *Prepared) start(ctx context.Context, runID, prompt string, out chan<- proto.Envelope) (*Session, error) { - if p.workspaceReadOnly { - return nil, errors.New("codex: read-only preparation cannot start execution") - } if out == nil || strings.TrimSpace(runID) == "" || strings.TrimSpace(prompt) == "" { return nil, errors.New("codex: start requires a run identity, prompt and output channel") } @@ -64,14 +56,6 @@ func (p *Prepared) start(ctx context.Context, runID, prompt string, out chan<- p _ = p.Close() } }() - if p.remote { - check, cancel := context.WithTimeout(ctx, 5*time.Second) - status, err := nativeEnvironmentStatus(check, p.session.rpc, "remote") - cancel() - if err != nil || status != "ready" { - return nil, errors.New("codex: prepared remote environment is no longer ready") - } - } p.mu.Lock() defer p.mu.Unlock() if p.closed || ctx.Err() != nil || p.session.cancelCtx.Err() != nil || !p.session.rpc.Alive() { @@ -105,9 +89,6 @@ func (p *Prepared) Close() error { p.session.cancelFn() err := p.session.rpc.Close() p.plan.Cleanup() - if err == nil && p.workspaceReadOnly { - return os.RemoveAll(p.plan.Cwd) - } return err } diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go index 68426b2e8..3775cf13a 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go @@ -2,8 +2,6 @@ package codex import ( "context" - "os" - "path/filepath" "reflect" "sync" "sync/atomic" @@ -68,54 +66,6 @@ func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { assertUnstartedCancellation(t, p) } -func TestPreparedCancelDuringStartReadiness(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.AgentSessionID = "requested-but-unobserved-thread" - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - before := len(preparationFrames(t, root)) - if err := os.WriteFile(filepath.Join(root, "remote-status"), []byte("blocked"), 0o600); err != nil { - t.Fatal(err) - } - finished := make(chan error, 1) - out := make(chan proto.Envelope, 8) - go func() { - session, err := p.Start(t.Context(), "run", "prompt", out) - if session != nil { - t.Error("cancelled readiness returned a Session") - } - finished <- err - }() - deadline := time.Now().Add(4 * time.Second) - for len(preparationFrames(t, root)) == before && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - frames := preparationFrames(t, root) - if len(frames) != before+1 || frames[before].Method != "environment/status" { - t.Fatal("Start did not enter its readiness recheck") - } - if err := p.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - select { - case err := <-finished: - if err == nil { - t.Fatal("cancelled Start succeeded") - } - case <-time.After(4 * time.Second): - t.Fatal("cancelled Start remained blocked") - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) - if len(out) != 0 { - t.Fatal("unused resource emitted Run output") - } -} - func assertUnstartedCancellation(t *testing.T, p *Prepared) { t.Helper() got := p.CancellationOutcome() diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_test.go b/apps/parsar-daemon/internal/agent/codex/prepared_test.go index 3a21e368b..6e65d87d4 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared_test.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared_test.go @@ -3,8 +3,6 @@ package codex import ( "context" "encoding/json" - "os" - "path/filepath" "strings" "sync" "testing" @@ -30,10 +28,11 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { t.Fatal("preparation did not retain its model catalog") } pid := p.session.rpc.cmd.Process.Pid + cfgPreparedCwd := p.plan.Cwd // Caller-owned data cannot revise the prepared native configuration. req.AgentOptions["model"] = "different-model" req.AgentSessionID = "different-thread" - req.RemoteEnvironment.WorkspaceDirectory = "/different-executor" + req.WorkDir = "/different-workspace" copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) out := make(chan proto.Envelope, 8) startCtx, stopStart := context.WithCancel(t.Context()) @@ -61,10 +60,11 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { t.Fatal("preparation and start used different children") } var params struct { - Model string `json:"model"` - ThreadID string `json:"threadId"` - DynamicTools []dynamicFunctionTool `json:"dynamicTools"` - Environments []EnvironmentSelection `json:"environments"` + Model string `json:"model"` + ThreadID string `json:"threadId"` + DynamicTools []dynamicFunctionTool `json:"dynamicTools"` + Cwd string `json:"cwd"` + Environments json.RawMessage `json:"environments"` } if err := json.Unmarshal(frame.Params, ¶ms); err != nil { t.Fatal(err) @@ -77,7 +77,7 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { t.Fatal("prepared resume changed") } - if frame.Method == "turn/start" && (len(params.Environments) != 1 || params.Environments[0].Cwd != "/executor-only") { + if len(params.Environments) != 0 || (frame.Method == "thread/start" && params.Cwd != cfgPreparedCwd) || p.plan.Cwd != cfgPreparedCwd { t.Fatal("prepared environment changed") } } @@ -85,7 +85,7 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { if resume { expectedThread = "thread/resume" } - if counts["initialize"] != 1 || counts["environment/info"] != 1 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { + if counts["initialize"] != 1 || counts["environment/status"] != 2 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { t.Fatal("unexpected native setup/start count", counts) } if err := session.Cancel(context.Background()); err != nil { @@ -102,7 +102,7 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { } func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { - for _, reason := range []string{"close", "owner cancelled", "rpc exited", "executor disconnected", "start cancelled"} { + for _, reason := range []string{"close", "owner cancelled", "rpc exited", "start cancelled"} { t.Run(reason, func(t *testing.T) { req, cfg, root := preparationFixture(t) owner, cancelOwner := context.WithCancel(t.Context()) @@ -124,10 +124,6 @@ func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { if err := p.session.rpc.Close(); err != nil { t.Fatal(err) } - case "executor disconnected": - if err := os.WriteFile(filepath.Join(root, "remote-status"), []byte("disconnected"), 0o600); err != nil { - t.Fatal(err) - } case "start cancelled": var cancel context.CancelFunc startCtx, cancel = context.WithCancel(t.Context()) @@ -148,11 +144,11 @@ func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { } count := 0 for _, frame := range preparationFrames(t, root) { - if frame.Method == "environment/info" { + if frame.Method == "environment/status" { count++ } } - if count != 1 { + if count != 2 { t.Fatal("failed start reconnected the native environment", count) } }) @@ -223,7 +219,7 @@ func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { } result <- err }() - waitPreparationMethod(t, root, "environment/info") + waitPreparationMethod(t, root, "environment/status") cancel() select { case err := <-result: diff --git a/apps/parsar-daemon/internal/agent/codex/private_harness.go b/apps/parsar-daemon/internal/agent/codex/private_harness.go deleted file mode 100644 index d07582089..000000000 --- a/apps/parsar-daemon/internal/agent/codex/private_harness.go +++ /dev/null @@ -1,112 +0,0 @@ -package codex - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "sync" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -// privateHarness owns only this child's local IPC directory. It neither grants -// Files authority nor settles remote operations when the child exits. -type privateHarness struct { - parent string - root string - environment string - readMu sync.Mutex - reading bool - uncertain bool -} - -func configurePrivateHarness(cfg *JSONRPCConfig, binary string, remote *proto.RemoteEnvironment) (*privateHarness, error) { - if binary == "" || remote == nil { - return nil, nil - } - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - return nil, errors.New("codex: private harness requires Linux amd64") - } - if !filepath.IsAbs(binary) || filepath.Clean(binary) != binary { - return nil, errors.New("codex: private harness requires a clean absolute binary path") - } - info, err := os.Stat(binary) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { - return nil, errors.New("codex: private harness executable unavailable") - } - helper, err := exec.LookPath(cfg.Binary) - if err != nil { - return nil, errors.New("codex: stock native helper unavailable") - } - helper, err = filepath.Abs(helper) - if err != nil { - return nil, err - } - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - if !filepath.IsAbs(home) { - return nil, errors.New("codex: private harness requires an absolute home directory") - } - // Native admission requires canonical ~/.parsar and trusted ancestors. Keep - // this path short independently of a potentially deep PARSAR_HOME profile. - base, err := filepath.EvalSymlinks(filepath.Join(home, ".parsar")) - if err != nil { - return nil, fmt.Errorf("codex: private harness state root: %w", err) - } - parent, err := os.MkdirTemp(base, "ch-") - if err != nil { - return nil, err - } - harness := &privateHarness{parent: parent, root: filepath.Join(parent, "native"), environment: remote.ID} - if len(filepath.Join(harness.root, "files.sock")) >= 104 { - harness.cleanup() - return nil, errors.New("codex: private harness socket path is too long") - } - cfg.Binary = binary - cfg.Env = append(cfg.Env, - "PARSAR_CODEX_HARNESS_NATIVE="+helper, - "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER="+os.Getenv("PARSAR_CODEX_DIRECTORY_HELPER"), - "PARSAR_CODEX_HARNESS_ENVIRONMENT="+remote.ID, - "PARSAR_CODEX_HARNESS_WORKSPACE="+remote.WorkspaceDirectory, - "PARSAR_CODEX_HARNESS_IPC_ROOT="+harness.root) - return harness, nil -} - -func (h *privateHarness) verify() error { - if h == nil { - return nil - } - info, err := os.Lstat(filepath.Join(h.root, "files.sock")) - if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 { - return errors.New("codex: private harness metadata endpoint unavailable") - } - return nil -} - -// Release only after the same RPC child has been reaped, including failed -// initialization and a Close deadline. A spawn failure owns no child. -func (h *privateHarness) releaseWith(rpc *JSONRPCClient) { - if h == nil { - return - } - if rpc.cmd == nil { - h.cleanup() - return - } - go func() { - <-rpc.Done() - h.cleanup() - }() -} - -func (h *privateHarness) cleanup() { - // Never recursively delete unexpected contents or another owner's directory. - _ = os.Remove(filepath.Join(h.root, "files.sock")) - _ = os.Remove(h.root) - _ = os.Remove(h.parent) -} diff --git a/apps/parsar-daemon/internal/agent/codex/private_harness_test.go b/apps/parsar-daemon/internal/agent/codex/private_harness_test.go deleted file mode 100644 index bfb666813..000000000 --- a/apps/parsar-daemon/internal/agent/codex/private_harness_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package codex - -import ( - "context" - "net" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func privateHarnessTestHome(t *testing.T) string { - t.Helper() - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - t.Skip("private Linux amd64 artifact") - } - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - base := filepath.Join(home, ".parsar") - if err = os.MkdirAll(base, 0700); err != nil { - t.Fatal(err) - } - home, err = os.MkdirTemp(base, "ht-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(home) }) - t.Setenv("HOME", home) - base = filepath.Join(home, ".parsar") - if err = os.Mkdir(base, 0700); err != nil { - t.Fatal(err) - } - return base -} - -func privateHarnessEnv(env []string, name string) string { - value := "" - for _, entry := range env { - if strings.HasPrefix(entry, name+"=") { - value = strings.TrimPrefix(entry, name+"=") - } - } - return value -} - -func awaitPrivateHarnessCleanup(t *testing.T, path string) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - if _, err := os.Lstat(path); os.IsNotExist(err) { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatal("private harness directory retained after child release") -} - -func TestPrivateHarnessBindingAndDefaultSelection(t *testing.T) { - base := privateHarnessTestHome(t) - cfg := JSONRPCConfig{Binary: "/missing-stock", Env: []string{"unchanged=value"}} - if h, err := configurePrivateHarness(&cfg, "relative-invalid", nil); h != nil || err != nil || cfg.Binary != "/missing-stock" || len(cfg.Env) != 1 { - t.Fatal("nonremote default changed") - } - remote := &proto.RemoteEnvironment{ID: "fixture", WorkspaceDirectory: "/remote"} - if h, err := configurePrivateHarness(&cfg, "", remote); h != nil || err != nil || len(cfg.Env) != 1 { - t.Fatal("stock remote default changed") - } - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - for _, path := range []string{"relative", filepath.Join(base, "missing"), base} { - if _, err := configurePrivateHarness(&cfg, path, remote); err == nil { - t.Fatal("invalid artifact admitted", path) - } - } - if _, err := configurePrivateHarness(&cfg, binary, remote); err == nil { - t.Fatal("missing helper admitted") - } - cfg.Binary = binary - t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/trusted/directory-helper") - cfg.Env = append(cfg.Env, "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER=/caller/override", "PARSAR_CODEX_HARNESS_ENVIRONMENT=wrong", "PARSAR_CODEX_HARNESS_IPC_ROOT=/wrong") - h, err := configurePrivateHarness(&cfg, binary, remote) - if err != nil { - t.Fatal(err) - } - defer h.cleanup() - if privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_DIRECTORY_HELPER") != "/trusted/directory-helper" { - t.Fatal("directory helper was not selected by operator") - } - if cfg.Binary != binary || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_NATIVE") != binary || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_ENVIRONMENT") != remote.ID || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_WORKSPACE") != remote.WorkspaceDirectory || privateHarnessEnv(cfg.Env, "PARSAR_CODEX_HARNESS_IPC_ROOT") != h.root { - t.Fatal("private binding not derived from operator and request") - } - if filepath.Dir(h.parent) != base { - t.Fatal("private IPC escaped home") - } - if _, err := os.Lstat(h.root); !os.IsNotExist(err) { - t.Fatal("native socket root already exists") - } - if info, err := os.Stat(h.parent); err != nil || info.Mode().Perm() != 0700 { - t.Fatal("IPC parent is not private") - } - if h.verify() == nil { - t.Fatal("missing endpoint accepted") - } -} - -func TestPrivateHarnessPreparationTransferAndRelease(t *testing.T) { - for _, start := range []bool{false, true} { - t.Run(map[bool]string{false: "unused", true: "transferred"}[start], func(t *testing.T) { - privateHarnessTestHome(t) - req, cfg, root := preparationFixture(t) - cfg.harnessBinary = cfg.codexBinary - t.Setenv("PARSAR_PRIVATE_HARNESS_FAKE", "1") - owner, cancel := context.WithCancel(t.Context()) - defer cancel() - p, err := newPreparation(owner, req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - ipc := privateHarnessEnv(p.session.rpc.cfg.Env, "PARSAR_CODEX_HARNESS_IPC_ROOT") - if ipc == "" { - t.Fatal("adapter did not configure the artifact") - } - assertPreparationOnly(t, root) - if start { - out := make(chan proto.Envelope, 32) - if _, err = p.Start(t.Context(), "run", "hello", out); err != nil { - t.Fatal(err) - } - waitPreparationMethod(t, root, "turn/start") - if _, err = p.Start(t.Context(), "second", "hello", out); err == nil { - t.Fatal("second Start accepted") - } - if err = p.Close(); err != nil { - t.Fatal(err) - } - if _, err = os.Lstat(ipc); err != nil { - t.Fatal("transfer lost IPC before Session release") - } - } - if err = p.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - awaitPrivateHarnessCleanup(t, filepath.Dir(ipc)) - waitPreparedRelease(t, p, root) - }) - } -} - -func TestPrivateHarnessRejectsOrdinaryBinaryBeforeStart(t *testing.T) { - base := privateHarnessTestHome(t) - req, cfg, root := preparationFixture(t) - cfg.harnessBinary = cfg.codexBinary - t.Setenv("PARSAR_PRIVATE_HARNESS_FAKE", "") - if p, err := newPreparation(t.Context(), req, cfg); err == nil { - _ = p.Close() - t.Fatal("ordinary binary admitted as integrated artifact") - } - assertPreparationOnly(t, root) - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - entries, err := os.ReadDir(base) - if err != nil { - t.Fatal(err) - } - if len(entries) == 0 { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatal("failed initialization retained private IPC allocation") -} - -func TestPrivateHarnessCleanupWaitsForRPCSettlement(t *testing.T) { - base := privateHarnessTestHome(t) - for _, spawned := range []bool{false, true} { - parent, err := os.MkdirTemp(base, "ch-") - if err != nil { - t.Fatal(err) - } - h := &privateHarness{parent: parent, root: filepath.Join(parent, "native")} - rpc := NewJSONRPCClient(JSONRPCConfig{}) - if spawned { - rpc.cmd = exec.Command("controlled-unreaped-owner") - } - h.releaseWith(rpc) - if spawned { - if _, err = os.Stat(parent); err != nil { - t.Fatal("allocation removed before child settlement") - } - close(rpc.doneCh) - } - awaitPrivateHarnessCleanup(t, parent) - } -} - -// Only the controlled native fixture uses this socket. Real artifact tests run -// the pinned executable and independently inspect actual remote metadata. -func fakePrivateHarnessEndpoint() { - if os.Getenv("PARSAR_PRIVATE_HARNESS_FAKE") != "1" { - return - } - root := os.Getenv("PARSAR_CODEX_HARNESS_IPC_ROOT") - if root == "" || os.Mkdir(root, 0700) != nil { - os.Exit(7) - } - path := filepath.Join(root, "files.sock") - listener, err := net.Listen("unix", path) - if err != nil || os.Chmod(path, 0600) != nil { - os.Exit(7) - } - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - _ = conn.Close() - } - }() -} diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/parsar-daemon/internal/agent/codex/protocol.go index 4dee13613..f24be9802 100644 --- a/apps/parsar-daemon/internal/agent/codex/protocol.go +++ b/apps/parsar-daemon/internal/agent/codex/protocol.go @@ -158,11 +158,10 @@ type SandboxPolicy struct { // --------------------------------------------------------------------------- type ThreadStartParams struct { - Environments []EnvironmentSelection `json:"environments,omitempty"` - Cwd string `json:"cwd"` - Model string `json:"model,omitempty"` - ModelProvider string `json:"modelProvider,omitempty"` - ApprovalPolicy AskForApproval `json:"approvalPolicy"` + Cwd string `json:"cwd"` + Model string `json:"model,omitempty"` + ModelProvider string `json:"modelProvider,omitempty"` + ApprovalPolicy AskForApproval `json:"approvalPolicy"` // Sandbox is the v0.141+ field name; previously called sandboxPolicy // and took a tagged-enum object. Wire format now is a kebab-case // string: "read-only" / "workspace-write" / "danger-full-access". @@ -231,10 +230,9 @@ type UserInput struct { } type TurnStartParams struct { - Environments []EnvironmentSelection `json:"environments,omitempty"` - ThreadID string `json:"threadId"` - Input []UserInput `json:"input"` - CollaborationMode *CollaborationMode `json:"collaborationMode,omitempty"` + ThreadID string `json:"threadId"` + Input []UserInput `json:"input"` + CollaborationMode *CollaborationMode `json:"collaborationMode,omitempty"` } type CollaborationModeKind string diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 229bb3525..f2bf9991e 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -27,7 +27,6 @@ const terminalSendTimeout = 2 * time.Second // through Factory which uses defaults. type sessionConfig struct { codexBinary string - harnessBinary string permissionProfile string runtimeNetwork agentnetwork.Policy runtimeNetworkError error @@ -39,7 +38,6 @@ func defaultSessionConfig() sessionConfig { policy, err := localworkspace.RuntimeNetworkPolicy() return sessionConfig{ codexBinary: defaultBinary(), - harnessBinary: os.Getenv("PARSAR_CODEX_HARNESS_BIN"), permissionProfile: os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE"), runtimeNetwork: policy, runtimeNetworkError: err, logger: obslog.Bg(), @@ -76,7 +74,6 @@ type Session struct { cfg sessionConfig out chan<- proto.Envelope rpc *JSONRPCClient - harness *privateHarness cancelCtx context.Context cancelFn context.CancelFunc diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index a4e556e55..28b541630 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -9,14 +9,13 @@ import ( ) func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, []string, error) { + if err := validateNativeTransportEnvironment(req); err != nil { + return SessionPlan{}, nil, err + } profile, err := managedPermissionProfile(req, cfg) if err != nil { return SessionPlan{}, nil, err } - if req.WorkspaceReadOnly { - plan, err := workspaceReadPlan(req) - return plan, nil, err - } mcpServers, err := publicMCPHTTPServers(req) if err != nil { return SessionPlan{}, nil, err @@ -84,7 +83,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) } } - } else if !req.DisableExecutionEnvironment && req.RemoteEnvironment == nil { + } else if !req.DisableExecutionEnvironment { var root string root, err = prepareManagedSkills(ctx, cfg.logger, req) if root != "" { @@ -96,9 +95,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } - if req.RemoteEnvironment != nil { - configureRemoteEnvironment(&plan, *req.RemoteEnvironment) - } plan.Env = append(plan.Env, mcpBearerEnv...) plan.Env = append(plan.Env, environmentMCPEnv...) if cfg.runtimeNetwork.Access == "restricted" { diff --git a/apps/parsar-daemon/internal/agent/codex/session_run.go b/apps/parsar-daemon/internal/agent/codex/session_run.go index 5a86016b5..a5e47d626 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_run.go +++ b/apps/parsar-daemon/internal/agent/codex/session_run.go @@ -27,9 +27,8 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { return } turnParams := TurnStartParams{ - ThreadID: s.currentThreadID(), - Input: input, - Environments: plan.Environments, + ThreadID: s.currentThreadID(), + Input: input, } if plan.CollaborationMode != "" { model := strings.TrimSpace(s.resolvedModel) diff --git a/apps/parsar-daemon/internal/agent/codex/session_thread.go b/apps/parsar-daemon/internal/agent/codex/session_thread.go index 6b466caec..151470741 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_thread.go +++ b/apps/parsar-daemon/internal/agent/codex/session_thread.go @@ -8,7 +8,6 @@ import ( func (s *Session) startThread(plan SessionPlan) error { params := ThreadStartParams{ Cwd: plan.Cwd, - Environments: plan.Environments, Model: plan.Model, ModelProvider: plan.ModelProvider, ApprovalPolicy: plan.ApprovalPolicy, diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_directory.go b/apps/parsar-daemon/internal/agent/codex/workspace_directory.go deleted file mode 100644 index ac7107bc5..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_directory.go +++ /dev/null @@ -1,115 +0,0 @@ -package codex - -import ( - "bytes" - "context" - "encoding/json" - "io" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" -) - -const workspaceDirectoryMaxEntries = 4096 - -var _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) -var _ agent.WorkspaceDirectoryLister = (*Session)(nil) - -func (p *Prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - p.mu.Lock() - if p.claimed || p.closed || p.started { - p.mu.Unlock() - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - frame, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return p.session.harness.readWorkspaceDirectory(ctx, frame, maxEntries) -} - -func (s *Session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - frame, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return s.harness.readWorkspaceDirectory(ctx, frame, maxEntries) -} - -func (s *Session) admitWorkspaceDirectory(ctx context.Context, path string, maxEntries int) ([]byte, error) { - if err := s.workspaceReadAvailable(ctx); err != nil { - return nil, err - } - if !workspaceRelativePath(path, true) || maxEntries < 1 || maxEntries > workspaceDirectoryMaxEntries { - return nil, agent.ErrWorkspaceReadInvalid - } - frame, err := json.Marshal(struct { - Environment string `json:"environment_id"` - Operation string `json:"operation"` - Path string `json:"path"` - MaxEntries int `json:"max_entries"` - }{s.harness.environment, "list_directory", path, maxEntries}) - if err != nil { - return nil, agent.ErrWorkspaceReadInvalid - } - return s.claimWorkspaceRead(frame) -} - -func (h *privateHarness) readWorkspaceDirectory(ctx context.Context, frame []byte, maxEntries int) (result agent.WorkspaceDirectoryResult, err error) { - err = h.exchangeWorkspaceRead(ctx, frame, maxEntries*2048+1024, func(response []byte) error { - var decodeErr error - result, decodeErr = decodeWorkspaceDirectory(response, maxEntries) - return decodeErr - }) - return result, err -} - -func decodeWorkspaceDirectory(frame []byte, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - var response struct { - Error *string `json:"error"` - Directory *struct { - Entries *[]struct { - Name string `json:"name"` - Kind string `json:"kind"` - SizeBytes *int64 `json:"size_bytes"` - } `json:"entries"` - Truncated *bool `json:"truncated"` - } `json:"directory"` - } - invalid := agent.ErrWorkspaceReadUncertain - decoder := json.NewDecoder(bytes.NewReader(frame)) - decoder.DisallowUnknownFields() - if decoder.Decode(&response) != nil || decoder.Decode(new(any)) != io.EOF || (response.Error == nil) == (response.Directory == nil) { - return agent.WorkspaceDirectoryResult{}, invalid - } - if response.Error != nil { - return agent.WorkspaceDirectoryResult{}, workspaceReadError(*response.Error) - } - directory := response.Directory - if directory.Entries == nil || directory.Truncated == nil || len(*directory.Entries) > maxEntries { - return agent.WorkspaceDirectoryResult{}, invalid - } - result := agent.WorkspaceDirectoryResult{Entries: make([]agent.WorkspaceDirectoryEntry, 0, len(*directory.Entries)), Truncated: *directory.Truncated} - seen := make(map[string]bool, len(*directory.Entries)) - for _, entry := range *directory.Entries { - if !workspaceRelativePath(entry.Name, false) || strings.Contains(entry.Name, "/") || seen[entry.Name] { - return agent.WorkspaceDirectoryResult{}, invalid - } - seen[entry.Name] = true - switch entry.Kind { - case "file": - if entry.SizeBytes == nil || *entry.SizeBytes < 0 { - return agent.WorkspaceDirectoryResult{}, invalid - } - case "directory", "symlink", "other": - if entry.SizeBytes != nil { - return agent.WorkspaceDirectoryResult{}, invalid - } - default: - return agent.WorkspaceDirectoryResult{}, invalid - } - result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) - } - return result, nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go deleted file mode 100644 index b243b1843..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_directory_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" -) - -const workspaceDirectorySuccess = `{"directory":{"entries":[{"name":"result.bin","kind":"file","size_bytes":4},{"name":"subdir","kind":"directory","size_bytes":null}],"truncated":false}}` + "\n" - -func TestWorkspaceDirectoryValidatesCompleteResponse(t *testing.T) { - result, err := decodeWorkspaceDirectory([]byte(workspaceDirectorySuccess), 2) - if err != nil || result.Truncated || len(result.Entries) != 2 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 4 || result.Entries[1].SizeBytes != nil { - t.Fatal(result, err) - } - for _, frame := range []string{ - `{"directory":{"entries":[],"truncated":null}}`, - `{"directory":{"entries":null,"truncated":false}}`, - `{"directory":{"entries":[{"name":"../other","kind":"file","size_bytes":4}],"truncated":false}}`, - `{"directory":{"entries":[{"name":"file","kind":"file"}],"truncated":false}}`, - `{"directory":{"entries":[{"name":"link","kind":"symlink","size_bytes":1}],"truncated":false}}`, - `{"directory":{"entries":[{"name":"file","kind":"file","size_bytes":-1}],"truncated":false}}`, - `{"directory":{"entries":[{"name":"same","kind":"directory"},{"name":"same","kind":"directory"}],"truncated":false}}`, - workspaceDirectorySuccess + `{}`, - } { - if got, err := decodeWorkspaceDirectory([]byte(frame), 2); !errors.Is(err, agent.ErrWorkspaceReadUncertain) || len(got.Entries) != 0 { - t.Fatalf("malformed directory succeeded: %+v %v", got, err) - } - } - if _, err := decodeWorkspaceDirectory([]byte(workspaceDirectorySuccess), 1); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("oversized response accepted", err) - } - result, err = decodeWorkspaceDirectory([]byte(`{"directory":{"entries":[],"truncated":true}}`), 1) - if err != nil || !result.Truncated { - t.Fatal("truncated observation lost", result, err) - } -} - -func TestWorkspaceDirectorySharesReadOwnership(t *testing.T) { - session, listener := workspaceReadFixture(t) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { - _, err := session.ListWorkspaceDirectory(ctx, "", 2) - done <- err - }() - conn, frame := workspaceReadConnection(t, listener) - if conn == nil { - return - } - defer conn.Close() - var request map[string]any - if json.Unmarshal(frame, &request) != nil || request["environment_id"] != "frozen-environment" || request["path"] != "" || request["operation"] != "list_directory" || request["max_entries"] != float64(2) { - t.Fatalf("directory binding changed: %s", frame) - } - cancel() - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { - t.Fatal("directory detach freed shared slot", err) - } - select { - case err := <-done: - t.Fatal("directory wait discarded", err) - default: - } - _, _ = conn.Write([]byte(workspaceDirectorySuccess)) - if err := <-done; err != nil { - t.Fatal(err) - } - for _, path := range []string{"/root", "a/../b", "a//b", ".", "../x", "a\\b", "a\n"} { - if _, err := session.ListWorkspaceDirectory(t.Context(), path, 2); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatal("invalid directory admitted", path, err) - } - } -} - -func TestWorkspaceDirectoryUncertaintyFencesFileReads(t *testing.T) { - session, listener := workspaceReadFixture(t) - done := make(chan struct{}) - go func() { - defer close(done) - conn, _ := workspaceReadConnection(t, listener) - if conn != nil { - _, _ = conn.Write([]byte("{broken}\n")) - _ = conn.Close() - } - }() - if _, err := session.ListWorkspaceDirectory(t.Context(), "", 2); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal(err) - } - <-done - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("uncertainty lost between operations", err) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go deleted file mode 100644 index 38c8f4908..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_preparation.go +++ /dev/null @@ -1,87 +0,0 @@ -package codex - -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/agentdaemon/proto" -) - -// Preserve only process/transport essentials, never ambient model credentials, -// native configuration selectors or runtime injection variables. -func workspaceReadEnvironment(environment []string) []string { - var result []string - for _, value := range environment { - key, _, _ := strings.Cut(value, "=") - switch key { - case "HOME", "PATH", "TMPDIR", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR", - "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy": - result = append(result, value) - } - } - return result -} - -func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { - if err := p.Close(); err != nil && p.workspaceReadOnly { - // A failed constructor still returns its cleanup owner to the dispatcher. - return p, errors.Join(cause, err) - } - return nil, cause -} - -// SupportsWorkspaceReadPreparation checks local prerequisites, not public admission. -// Native connection and the installed executor helper are verified per operation. -func SupportsWorkspaceReadPreparation() bool { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - return false - } - binary, helper := os.Getenv("PARSAR_CODEX_HARNESS_BIN"), os.Getenv("PARSAR_CODEX_DIRECTORY_HELPER") - if !filepath.IsAbs(binary) || filepath.Clean(binary) != binary || !filepath.IsAbs(helper) || filepath.Clean(helper) != helper { - return false - } - info, err := os.Stat(binary) - return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 -} - -func workspaceReadPlan(req proto.PromptRequestPayload) (SessionPlan, error) { - root, err := paths.Root() - if err != nil { - return SessionPlan{}, err - } - base := filepath.Join(root, "parsar-daemon", "workspace-read") - if err := os.MkdirAll(base, 0o700); err != nil { - return SessionPlan{}, err - } - state, err := os.MkdirTemp(base, "read-") - if err != nil { - return SessionPlan{}, err - } - plan := SessionPlan{Cwd: state, Env: []string{"CODEX_HOME=" + state, "DISABLE_TELEMETRY=1"}, Cleanup: func() { _ = os.RemoveAll(state) }} - configureRemoteEnvironment(&plan, *req.RemoteEnvironment) - return plan, nil -} - -// A failed Close retains state until the same child exits. A successful Close -// performs cleanup synchronously, including another caller's ongoing cleanup. -func readPreparationCleanup(rpc *JSONRPCClient, cleanup func()) func() { - return func() { - rpc.mu.Lock() - cmd := rpc.cmd - rpc.mu.Unlock() - if cmd == nil || cmd.Process == nil { - cleanup() - return - } - select { - case <-rpc.Done(): - cleanup() - default: - go func() { <-rpc.Done(); cleanup() }() - } - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go deleted file mode 100644 index 2e91abe49..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_failure_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package codex - -import ( - "context" - "errors" - "os" - "os/exec" - "sync" - "testing" -) - -func TestFailedReadPreparationRetainsUnreapedOwner(t *testing.T) { - state := t.TempDir() - cmd := exec.Command("sh", "-c", "exit 0") - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - rpc := NewJSONRPCClient(JSONRPCConfig{}) - rpc.cmd, rpc.alive = cmd, true - var reap sync.Once - t.Cleanup(func() { _ = cmd.Process.Kill(); reap.Do(rpc.waitChild) }) - _, cancel := context.WithCancel(t.Context()) - cleanup := readPreparationCleanup(rpc, sync.OnceFunc(func() { _ = os.RemoveAll(state) })) - p := &Prepared{workspaceReadOnly: true, session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cwd: state, Cleanup: cleanup}} - cause := errors.New("controlled initialization failure") - owner, err := p.preparationFailed(cause) - if owner != p || !errors.Is(err, cause) || !errors.Is(err, context.DeadlineExceeded) { - t.Fatal("construction failure discarded an unconfirmed resource", err) - } - if _, err := os.Stat(state); err != nil { - t.Fatal("unreaped owner lost temporary state", err) - } - reap.Do(rpc.waitChild) - if err := owner.Close(); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(state); !os.IsNotExist(err) { - t.Fatal("confirmed cleanup retained temporary state", err) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go deleted file mode 100644 index 61b594e0e..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_preparation_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package codex - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestWorkspaceReadPreparationLeavesExecutionStateUntouched(t *testing.T) { - privateHarnessTestHome(t) - request, cfg, root := preparationFixture(t) - cfg.harnessBinary = cfg.codexBinary - // Put fixture controls in the executable, not in the sanitized child environment. - body, err := os.ReadFile(cfg.codexBinary) - if err != nil { - t.Fatal(err) - } - controls := "export PARSAR_PREPARATION_FAKE=1 PARSAR_PRIVATE_HARNESS_FAKE=1\n" - for _, key := range []string{"PARSAR_PREPARATION_FRAMES", "PARSAR_PREPARATION_STATUS"} { - controls += "export " + key + "='" + strings.ReplaceAll(os.Getenv(key), "'", "'\\''") + "'\n" - } - if err := os.WriteFile(cfg.codexBinary, []byte(strings.Replace(string(body), "exec ", controls+"exec ", 1)), 0700); err != nil { - t.Fatal(err) - } - request.WorkspaceReadOnly = true - request.WorkDir, request.AgentOptions, request.FunctionTools = "", nil, nil - stable, err := allocCodexHome(request.AgentStateKey) - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"config.toml", "history.jsonl"} { - if err := os.WriteFile(filepath.Join(stable, name), []byte("preserve original state"), 0600); err != nil { - t.Fatal(err) - } - } - p, err := newPreparation(t.Context(), request, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - if p.plan.Cwd == stable || privateHarnessEnv(p.plan.Env, "CODEX_HOME") != p.plan.Cwd || p.plan.Model != "" || p.plan.ModelProvider != "" { - t.Fatal("read preparation reused execution configuration") - } - if _, err := p.Start(t.Context(), "run", "do work", make(chan proto.Envelope, 1)); err == nil { - t.Fatal("read-only owner started execution") - } - assertPreparationOnly(t, root) - for _, name := range []string{"config.toml", "history.jsonl"} { - data, err := os.ReadFile(filepath.Join(stable, name)) - if err != nil || string(data) != "preserve original state" { - t.Fatal("original execution state changed", name, err) - } - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(p.plan.Cwd); !os.IsNotExist(err) { - t.Fatal("successful close left read state", err) - } -} - -func TestWorkspaceReadEnvironmentExcludesAmbientCredentials(t *testing.T) { - input := []string{"PATH=/usr/bin", "HOME=/operator", "HTTPS_PROXY=http://proxy", "OPENAI_API_KEY=sentinel", "ANTHROPIC_API_KEY=sentinel", "CUSTOM_PROVIDER_SECRET=sentinel", "CODEX_HOME=/execution", "LD_PRELOAD=/inject", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=old"} - got := workspaceReadEnvironment(input) - if strings.Join(got, "\n") != strings.Join(input[:3], "\n") { - t.Fatal("read child inherited execution configuration or credentials") - } -} - -func TestWorkspaceReadPreparationRejectsExecutionConfiguration(t *testing.T) { - request, cfg, _ := preparationFixture(t) - request.WorkspaceReadOnly = true - if _, err := newPreparation(context.Background(), request, cfg); err == nil { - t.Fatal("execution settings accepted as read-only") - } - request.WorkDir, request.AgentOptions, request.FunctionTools = "", nil, nil - if !proto.ValidWorkspaceReadPreparation(request) { - t.Fatal("minimal read request rejected") - } - if _, err := newPreparation(context.Background(), request, cfg); err == nil { - t.Fatal("stock harness admitted a read-only preparation") - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_read.go b/apps/parsar-daemon/internal/agent/codex/workspace_read.go deleted file mode 100644 index 6d86fe954..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_read.go +++ /dev/null @@ -1,193 +0,0 @@ -package codex - -import ( - "bufio" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "io" - "io/fs" - "net" - "path/filepath" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" -) - -const workspaceReadMaxBytes = 8 << 20 -const workspaceReadTimeout = 12 * time.Second - -var _ agent.WorkspaceReader = (*Prepared)(nil) -var _ agent.WorkspaceReader = (*Session)(nil) - -func (p *Prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - p.mu.Lock() - if p.claimed || p.closed || p.started { - p.mu.Unlock() - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable - } - frame, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return p.session.harness.readWorkspaceFile(ctx, frame, maxBytes) -} - -func (s *Session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - frame, err := s.admitWorkspaceRead(ctx, path, maxBytes) - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return s.harness.readWorkspaceFile(ctx, frame, maxBytes) -} - -func (s *Session) admitWorkspaceRead(ctx context.Context, path string, maxBytes int) ([]byte, error) { - if err := s.workspaceReadAvailable(ctx); err != nil { - return nil, err - } - if maxBytes < 1 || maxBytes > workspaceReadMaxBytes || !workspaceRelativePath(path, false) { - return nil, agent.ErrWorkspaceReadInvalid - } - frame, err := json.Marshal(struct { - Environment string `json:"environment_id"` - Operation string `json:"operation"` - Path string `json:"path"` - MaxBytes int `json:"max_bytes"` - }{s.harness.environment, "read", path, maxBytes}) - if err != nil { - return nil, agent.ErrWorkspaceReadInvalid - } - return s.claimWorkspaceRead(frame) -} - -func (s *Session) workspaceReadAvailable(ctx context.Context) error { - if s.harness == nil { - return agent.ErrWorkspaceReadUnsupported - } - if ctx == nil || ctx.Err() != nil || s.cancelCtx.Err() != nil || s.cancelled.Load() || s.terminal.Load() || !s.rpc.Alive() { - return agent.ErrWorkspaceReadUnavailable - } - return nil -} - -func workspaceRelativePath(path string, allowRoot bool) bool { - if path == "" { - return allowRoot - } - if len(path) > 8192 || strings.ContainsAny(path, "\x00\\\r\n") { - return false - } - for _, part := range strings.Split(path, "/") { - if part == "" || part == "." || part == ".." { - return false - } - } - return true -} - -func (s *Session) claimWorkspaceRead(frame []byte) ([]byte, error) { - if len(frame)+1 > 8192 { - return nil, agent.ErrWorkspaceReadInvalid - } - h := s.harness - h.readMu.Lock() - defer h.readMu.Unlock() - if h.uncertain { - return nil, agent.ErrWorkspaceReadUncertain - } - if h.reading { - return nil, agent.ErrWorkspaceReadBusy - } - h.reading = true - return append(frame, '\n'), nil -} - -func (h *privateHarness) readWorkspaceFile(ctx context.Context, frame []byte, maxBytes int) (result agent.WorkspaceReadResult, err error) { - err = h.exchangeWorkspaceRead(ctx, frame, base64.StdEncoding.EncodedLen(maxBytes)+1024, func(response []byte) error { - var decodeErr error - result, decodeErr = decodeWorkspaceRead(response, maxBytes) - return decodeErr - }) - return result, err -} - -func (h *privateHarness) exchangeWorkspaceRead(ctx context.Context, frame []byte, limit int, decode func([]byte) error) (err error) { - defer func() { - h.readMu.Lock() - h.reading = false - h.uncertain = h.uncertain || errors.Is(err, agent.ErrWorkspaceReadUncertain) - h.readMu.Unlock() - }() - deadline := time.Now().Add(workspaceReadTimeout) - if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { - deadline = requested - } - // Cancellation cannot discard an admitted native wait; only its fixed deadline can. - operation, cancel := context.WithDeadline(context.WithoutCancel(ctx), deadline) - defer cancel() - conn, dialErr := (&net.Dialer{}).DialContext(operation, "unix", filepath.Join(h.root, "files.sock")) - if dialErr != nil { - return agent.ErrWorkspaceReadUnavailable - } - defer conn.Close() - if conn.SetDeadline(deadline) != nil { - return agent.ErrWorkspaceReadUnavailable - } - if n, writeErr := conn.Write(frame); writeErr != nil || n != len(frame) { - return agent.ErrWorkspaceReadUncertain - } - response, readErr := bufio.NewReader(io.LimitReader(conn, int64(limit+1))).ReadBytes('\n') - if readErr != nil || len(response) > limit { - return agent.ErrWorkspaceReadUncertain - } - return decode(response) -} - -func decodeWorkspaceRead(frame []byte, maxBytes int) (agent.WorkspaceReadResult, error) { - var response struct { - Error *string `json:"error"` - Read *struct { - Data *string `json:"data_base64"` - Truncated *bool `json:"truncated"` - Closed *bool `json:"close_acknowledged"` - } `json:"read"` - } - decoder := json.NewDecoder(bytes.NewReader(frame)) - decoder.DisallowUnknownFields() - if decoder.Decode(&response) != nil || decoder.Decode(new(any)) != io.EOF || (response.Error == nil) == (response.Read == nil) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain - } - if response.Error != nil { - return agent.WorkspaceReadResult{}, workspaceReadError(*response.Error) - } - read := response.Read - if read.Data == nil || read.Truncated == nil || read.Closed == nil || !*read.Closed { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain - } - data, err := base64.StdEncoding.Strict().DecodeString(*read.Data) - if err != nil || base64.StdEncoding.EncodeToString(data) != *read.Data || len(data) > maxBytes || (*read.Truncated && len(data) != maxBytes) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain - } - return agent.WorkspaceReadResult{Data: data, Truncated: *read.Truncated}, nil -} - -func workspaceReadError(code string) error { - switch code { - case "unsupported": - return agent.ErrWorkspaceReadUnsupported - case "not_found": - return fs.ErrNotExist - case "permission_denied": - return fs.ErrPermission - case "invalid_request", "invalid_path": - return agent.ErrWorkspaceReadInvalid - case "environment_unavailable", "wrong_environment", "native_error", "too_large": - return agent.ErrWorkspaceReadUnavailable - default: - return agent.ErrWorkspaceReadUncertain - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go b/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go deleted file mode 100644 index 3a0e05143..000000000 --- a/apps/parsar-daemon/internal/agent/codex/workspace_read_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package codex - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "io/fs" - "net" - "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" -) - -const workspaceReadSuccess = `{"read":{"data_base64":"AAEC/w==","truncated":false,"close_acknowledged":true}}` + "\n" - -func workspaceReadFixture(t *testing.T) (*Session, net.Listener) { - t.Helper() - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - base := filepath.Join(home, ".parsar") - if err := os.MkdirAll(base, 0700); err != nil { - t.Fatal(err) - } - root, err := os.MkdirTemp(base, "wr-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(root) }) - listener, err := net.Listen("unix", filepath.Join(root, "files.sock")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = listener.Close() }) - owner, cancel := context.WithCancel(t.Context()) - t.Cleanup(cancel) - session := &Session{ - harness: &privateHarness{root: root, environment: "frozen-environment"}, - rpc: &JSONRPCClient{alive: true}, cancelCtx: owner, cancelFn: cancel, - } - return session, listener -} - -func workspaceReadConnection(t *testing.T, listener net.Listener) (net.Conn, []byte) { - t.Helper() - conn, err := listener.Accept() - if err != nil { - t.Error(err) - return nil, nil - } - if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { - t.Error(err) - } - frame, err := bufio.NewReader(conn).ReadBytes('\n') - if err != nil { - t.Error(err) - } - return conn, frame -} - -func TestWorkspaceReadValidatesAcknowledgedResult(t *testing.T) { - for _, test := range []struct { - name, response string - limit int - want error - truncated bool - }{ - {name: "binary", response: workspaceReadSuccess, limit: 4}, - {name: "truncated", response: strings.Replace(workspaceReadSuccess, "false", "true", 1), limit: 4, truncated: true}, - {name: "empty", response: `{"read":{"data_base64":"","truncated":false,"close_acknowledged":true}}`, limit: 4}, - {name: "not found", response: `{"error":"not_found"}`, limit: 4, want: fs.ErrNotExist}, - {name: "permission", response: `{"error":"permission_denied"}`, limit: 4, want: fs.ErrPermission}, - {name: "native error", response: `{"error":"native_error"}`, limit: 4, want: agent.ErrWorkspaceReadUnavailable}, - {name: "unknown error", response: `{"error":"secret native detail"}`, limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "no close", response: strings.Replace(workspaceReadSuccess, `,"close_acknowledged":true`, "", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "false close", response: strings.Replace(workspaceReadSuccess, `"close_acknowledged":true`, `"close_acknowledged":false`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "no truncation", response: strings.Replace(workspaceReadSuccess, `,"truncated":false`, "", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "oversize", response: workspaceReadSuccess, limit: 3, want: agent.ErrWorkspaceReadUncertain}, - {name: "short truncation", response: strings.Replace(workspaceReadSuccess, "false", "true", 1), limit: 5, want: agent.ErrWorkspaceReadUncertain}, - {name: "bad base64", response: strings.Replace(workspaceReadSuccess, "AAEC/w==", "%%%", 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "trailing frame", response: workspaceReadSuccess + `{}`, limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "unknown field", response: strings.Replace(workspaceReadSuccess, `"read":`, `"extra":true,"read":`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - {name: "ambiguous", response: strings.Replace(workspaceReadSuccess, `"read":`, `"error":"not_found","read":`, 1), limit: 4, want: agent.ErrWorkspaceReadUncertain}, - } { - t.Run(test.name, func(t *testing.T) { - result, err := decodeWorkspaceRead([]byte(test.response), test.limit) - if !errors.Is(err, test.want) || result.Truncated != test.truncated { - t.Fatalf("unexpected result: %+v, %v", result, err) - } - if err != nil && len(result.Data) != 0 { - t.Fatal("failed read returned partial bytes") - } - if err == nil && test.name != "empty" && string(result.Data) != string([]byte{0, 1, 2, 255}) { - t.Fatal("binary bytes changed") - } - }) - } -} - -func TestWorkspaceReadFrozenBindingAndAdmission(t *testing.T) { - session, listener := workspaceReadFixture(t) - for _, path := range []string{"", "/absolute", "../escape", "a/../b", "a\\b", "a\n", strings.Repeat("x", 8192)} { - if _, err := session.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("path %q admitted: %v", path, err) - } - } - for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := session.ReadWorkspaceFile(t.Context(), "file", limit); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatalf("limit %d admitted: %v", limit, err) - } - } - done := make(chan struct{}) - go func() { - defer close(done) - conn, frame := workspaceReadConnection(t, listener) - if conn == nil { - return - } - defer conn.Close() - var request map[string]any - if err := json.Unmarshal(frame, &request); err != nil || len(request) != 4 || request["environment_id"] != "frozen-environment" || request["operation"] != "read" || request["path"] != "dir/file" || request["max_bytes"] != float64(4) { - t.Errorf("binding changed: %s", frame) - } - _, _ = conn.Write([]byte(workspaceReadSuccess)) - }() - if result, err := session.ReadWorkspaceFile(t.Context(), "dir/file", 4); err != nil || len(result.Data) != 4 { - t.Fatalf("read: %+v %v", result, err) - } - <-done - if _, err := (&Session{}).ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnsupported) { - t.Fatal("stock resource admitted read", err) - } - session.cancelFn() - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { - t.Fatal("cancelled owner admitted read", err) - } -} - -func TestWorkspaceReadRetainsCancelledObservationAndBusySlot(t *testing.T) { - session, listener := workspaceReadFixture(t) - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - result := make(chan error, 1) - go func() { - _, err := session.ReadWorkspaceFile(ctx, "file", 4) - result <- err - }() - conn, _ := workspaceReadConnection(t, listener) - if conn == nil { - return - } - defer conn.Close() - cancel() - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { - t.Fatal("cancelled observer freed read slot", err) - } - select { - case err := <-result: - t.Fatal("cancelled observation discarded native wait", err) - default: - } - _, _ = conn.Write([]byte(workspaceReadSuccess)) - if err := <-result; err != nil { - t.Fatal("acknowledged result lost after observation cancellation", err) - } -} - -func TestWorkspaceReadUncertaintyStopsLaterReads(t *testing.T) { - for _, response := range []string{"", "{broken}\n", strings.Repeat("x", 2048) + "\n"} { - t.Run(response[:min(len(response), 8)], func(t *testing.T) { - session, listener := workspaceReadFixture(t) - done := make(chan struct{}) - go func() { - defer close(done) - conn, _ := workspaceReadConnection(t, listener) - if conn != nil { - _, _ = conn.Write([]byte(response)) - _ = conn.Close() - } - }() - if result, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) || len(result.Data) != 0 { - t.Fatal("ambiguous read succeeded", result, err) - } - <-done - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("uncertain owner admitted another read", err) - } - }) - } -} - -func TestWorkspaceReadDeadlineRetainsUncertainty(t *testing.T) { - session, listener := workspaceReadFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - result := make(chan error, 1) - go func() { - _, err := session.ReadWorkspaceFile(ctx, "file", 4) - result <- err - }() - conn, _ := workspaceReadConnection(t, listener) - if conn == nil { - return - } - defer conn.Close() - if err := <-result; !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("deadline did not preserve uncertainty", err) - } - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("deadline admitted a replacement read", err) - } -} - -func TestWorkspaceReadOwnerExitDoesNotEstablishSettlement(t *testing.T) { - session, listener := workspaceReadFixture(t) - result := make(chan error, 1) - go func() { - _, err := session.ReadWorkspaceFile(t.Context(), "file", 4) - result <- err - }() - conn, _ := workspaceReadConnection(t, listener) - if conn == nil { - return - } - session.cancelFn() - _ = conn.Close() - if err := <-result; !errors.Is(err, agent.ErrWorkspaceReadUncertain) { - t.Fatal("owner exit reported read settlement", err) - } - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { - t.Fatal("exited owner admitted read", err) - } -} - -func TestWorkspaceReadFollowsPreparedTransfer(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - fixture, listener := workspaceReadFixture(t) - p.session.harness = fixture.harness - result := make(chan error, 1) - go func() { - _, err := p.ReadWorkspaceFile(t.Context(), "file", 4) - result <- err - }() - conn, _ := workspaceReadConnection(t, listener) - if conn == nil { - return - } - defer conn.Close() - started, err := p.Start(t.Context(), "actual-run", "actual prompt", make(chan proto.Envelope, 16)) - if err != nil { - t.Fatal(err) - } - session := started.(*Session) - defer session.Cancel(context.Background()) - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadUnavailable) { - t.Fatal("transferred preparation admitted read", err) - } - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); !errors.Is(err, agent.ErrWorkspaceReadBusy) { - t.Fatal("transfer lost admitted read", err) - } - _, _ = conn.Write([]byte(workspaceReadSuccess)) - if err := <-result; err != nil { - t.Fatal("read failed across Start", err) - } - waitPreparationMethod(t, root, "turn/start") - done := make(chan struct{}) - go func() { - defer close(done) - conn, _ := workspaceReadConnection(t, listener) - if conn != nil { - _, _ = conn.Write([]byte(workspaceReadSuccess)) - _ = conn.Close() - } - }() - if _, err := session.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { - t.Fatal("transferred Session cannot read", err) - } - <-done -} diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index 5b0c829a5..5aa6d516a 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.DisableSubagents || req.WorkDir != "" || req.AgentStateKey == "" || req.RemoteEnvironment != nil || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || 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.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 2b7be21af..b558380ba 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -51,7 +51,7 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { - 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.RemoteEnvironment != nil || req.WorkspaceReadOnly { + if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } servers, err := environmentMCP(req.LocalEnvironment) diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index 4f6a10552..bbb2f4310 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -150,12 +150,9 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a out.Codex.Available = true out.Codex.Version = codexVersion out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) - out.Codex.Capabilities.RemoteEnvironment = codex.SupportsRemoteEnvironment(codexVersion) out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) out.Codex.Capabilities.LocalEnvironmentNetworkPolicy = codex.SupportsLocalNetworkPolicy(codexVersion) - out.Codex.Capabilities.MCPHTTPRemoteEnvironment = out.Codex.Capabilities.RemoteEnvironment - out.Codex.Capabilities.MCPHTTPRequired = out.Codex.Capabilities.RemoteEnvironment - out.Codex.Capabilities.MCPHTTPRemoteBearerAuth = out.Codex.Capabilities.RemoteEnvironment + 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, "parsar-daemon: Codex CLI not found on PATH; codex unavailable.") diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/parsar-daemon/internal/cli/agent_registration.go index 51b7797dd..17126ff5a 100644 --- a/apps/parsar-daemon/internal/cli/agent_registration.go +++ b/apps/parsar-daemon/internal/cli/agent_registration.go @@ -16,8 +16,8 @@ func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, s 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)) - if agentCLIs.Codex.Available && (agentCLIs.Codex.Capabilities.RemoteEnvironment || agentCLIs.Codex.Capabilities.LocalEnvironment) { - registry.RegisterPreparation("codex", codex.SupportsWorkspaceReadPreparation() || agentCLIs.Codex.Capabilities.LocalEnvironment, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + 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) if prepared == nil { return nil, err diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 385e9f529..6e78ab5a5 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -56,11 +56,14 @@ const ( func runConnect(ctx *runContext, args []string) error { fs := newFlagSet("connect") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for reading legacy auth.json state or writing pid/log files") - background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") - serverURL = fs.String("url", "", "Parsar server base URL; with --token, pair inline before connecting") - token = fs.String("token", "", "pairing token; with --url, connect consumes it without writing auth.json") - deviceName = fs.String("device-name", "", "human label for inline pairing (defaults to hostname)") + profile = fs.String("profile", paths.DefaultProfile, "profile name for reading legacy auth.json state or writing pid/log files") + background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") + serverURL = fs.String("url", "", "Parsar server base URL; with --token, pair inline before connecting") + token = fs.String("token", "", "pairing token; with --url, connect consumes it without writing auth.json") + deviceName = fs.String("device-name", "", "human label for inline pairing (defaults to hostname)") + remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") + environment = fs.String("environment-id", "", "self-hosted Environment ID") + credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") ) if err := fs.Parse(args); err != nil { return fmt.Errorf("connect: parse flags: %w", err) @@ -76,6 +79,12 @@ func runConnect(ctx *runContext, args []string) error { if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } + 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) + } inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" if inlinePair { @@ -234,6 +243,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, "") +} + +func mainLoopRemote(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 @@ -262,7 +275,13 @@ func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agent }() bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) - boot, err := transport.Bootstrap(bootCtx, prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) + var boot *transport.BootstrapResponse + var err error + if remote == "" { + boot, err = transport.Bootstrap(bootCtx, prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) + } else { + boot, err = environmentBootstrap(bootCtx, prof, remote) + } bootCancel() if err != nil { return fmt.Errorf("connect: bootstrap: %w", err) @@ -277,7 +296,7 @@ func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agent registerAgentKinds(registry, agentCLIs, prof.ServerURL) dial := func(ctx context.Context) (*transport.Conn, error) { - return transport.Dial(ctx, transport.DialOptions{ + conn, err := transport.Dial(ctx, transport.DialOptions{ WSURL: wsURL, DeviceID: boot.DeviceID, Credential: prof.RunnerCredential, @@ -287,6 +306,13 @@ func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agent // in heartbeat's DaemonVersion field. DaemonVersion: proto.Version, }) + if remote != "" && err != nil { + if errors.Is(err, transport.ErrPermanent) { + return nil, fmt.Errorf("Environment connection rejected: %w", transport.ErrPermanent) + } + return nil, errors.New("Environment connection failed") + } + return conn, err } for { diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go new file mode 100644 index 000000000..752bdebcc --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -0,0 +1,158 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/google/uuid" +) + +type environmentEnrollment struct { + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + WorkspaceDirectory string `json:"workspace_directory"` +} + +func environmentClient() *http.Client { + return &http.Client{Timeout: bootstrapTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} +} + +func environmentBase(remote string) (string, error) { + u, err := url.Parse(remote) + if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawPath != "" || u.Path != "/api/v1/agent-daemon/ws" || strings.TrimSpace(remote) != remote { + return "", errors.New("connect: invalid Environment remote_url") + } + switch u.Scheme { + case "wss": + u.Scheme = "https" + case "ws": + ip := net.ParseIP(u.Hostname()) + if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { + return "", errors.New("connect: Environment remote_url requires TLS outside loopback") + } + u.Scheme = "http" + default: + return "", errors.New("connect: Environment remote_url must use ws or wss") + } + u.Path = "/api/v1" + return u.String(), nil +} + +func environmentUUID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} + +func executorCredential(path, environment string) (string, error) { + raw, err := readEnvironmentPrivateFile(path) + if err != nil { + return "", errors.New("connect: executor credential must be a protected owned JSON file") + } + var key struct { + KeyID string `json:"key_id"` + Token string `json:"executor_token"` + EnvironmentID string `json:"environment_id,omitempty"` + } + if decodeEnvironmentJSON(raw, &key) != nil || !environmentUUID(key.KeyID) || key.Token == "" || strings.ContainsAny(key.Token, " \t\r\n\x00") || (key.EnvironmentID != "" && key.EnvironmentID != environment) { + return "", errors.New("connect: invalid executor credential or Environment restriction") + } + return key.Token, nil +} + +func decodeEnvironmentJSON(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if decoder.Decode(new(any)) != io.EOF { + return errors.New("trailing JSON") + } + return nil +} + +func enrollEnvironment(ctx context.Context, client *http.Client, base, environment, credential string) (environmentEnrollment, error) { + var out environmentEnrollment + body, _ := json.Marshal(map[string]string{"environment_id": environment}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/agent-daemon/enroll", bytes.NewReader(body)) + if err != nil { + return out, errors.New("connect: invalid enrollment request") + } + req.Header.Set("Authorization", "Bearer "+credential) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return out, errors.New("connect: Environment enrollment transport failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + 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" { + return environmentEnrollment{}, errors.New("connect: invalid Environment enrollment response") + } + return out, nil +} + +func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) (*transport.BootstrapResponse, error) { + boot, err := transport.BootstrapWithClient(ctx, environmentClient(), prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) + if err != nil { + return nil, errors.New("connect: Environment bootstrap failed") + } + if boot.DeviceID != prof.RuntimeID || boot.WSURL != remote { + return nil, errors.New("connect: Environment bootstrap changed the bound device or remote_url") + } + return boot, nil +} + +func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { + base, err := environmentBase(remote) + if err != nil { + return err + } + if !environmentUUID(environment) { + return errors.New("connect: canonical Environment ID required") + } + if err = checkEnvironmentTarget(remote, environment); err != nil { + return err + } + credential, err := executorCredential(credentialFile, environment) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), bootstrapTimeout) + defer cancel() + bound, err := enrollEnvironment(ctx, environmentClient(), base, environment, credential) + if 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) + } + // Discovery consumes the immutable Runtime binding; it must follow enrollment. + discovery, err := preflightAgentCLIs(rc, profile) + if err != nil { + return err + } + prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} + return mainLoopRemote(rc, profile, prof, discovery, remote) +} diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go new file mode 100644 index 000000000..ff635bfae --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -0,0 +1,191 @@ +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" +) + +// 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"` +} + +// Check persisted ownership before transmitting the executor credential. +func checkEnvironmentTarget(remote, environment string) error { + root, err := paths.Root() + if err != nil { + return errors.New("connect: Runtime state unavailable") + } + raw, err := readEnvironmentPrivateFile(filepath.Join(root, "parsar-daemon", "environment.json")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + var prior environmentBinding + if err != nil || decodeEnvironmentJSON(raw, &prior) != nil || prior.RemoteURL != remote || prior.Enrollment.EnvironmentID != environment { + return errors.New("connect: Runtime belongs to a different Environment or history") + } + return nil +} + +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 +} + +func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { + root, err := paths.Root() + if err != nil || !filepath.IsAbs(root) { + return errors.New("connect: absolute Runtime state directory required") + } + if err = os.MkdirAll(root, 0700); err != nil { + return errors.New("connect: Runtime state directory unavailable") + } + workspace := os.Getenv("PARSAR_RUNTIME_WORKSPACE") + if workspace != "/environment/workspace" { + return errors.New("connect: packaged /workspace Runtime required") + } + for key, value := range map[string]string{ + "PARSAR_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, + "PARSAR_RUNTIME_SESSION_ID": bound.SessionID, + "PARSAR_RUNTIME_NETWORK_ACCESS": "enabled", + } { + if previous := os.Getenv(key); previous != "" && previous != value { + return errors.New("connect: conflicting Runtime identity or policy") + } + } + if domains := os.Getenv("PARSAR_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") + } + 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, "parsar-daemon")) + credentialPath, credentialErr := filepath.EvalSymlinks(credentialFile) + if err != nil || credentialErr != nil { + return errors.New("connect: protected daemon credential directory required") + } + 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} + if err = saveEnvironmentBinding(root, want); err != nil { + return err + } + for key, value := range map[string]string{"PARSAR_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID": bound.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS": "enabled"} { + if err = os.Setenv(key, value); err != nil { + return errors.New("connect: Runtime identity configuration failed") + } + } + local, err := localworkspace.Load() + if err != nil || local == nil { + return errors.New("connect: packaged Runtime binding or helpers unavailable") + } + return nil +} + +func saveEnvironmentBinding(root string, want environmentBinding) error { + // All three native sandboxes protect this existing daemon state directory. + dir := filepath.Join(root, "parsar-daemon") + if err := os.MkdirAll(dir, 0700); 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") + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || st.Uid != uint32(os.Getuid()) { + return errors.New("connect: Runtime state directory must be owned") + } + } + path := filepath.Join(dir, "environment.json") + raw, err := readEnvironmentPrivateFile(path) + if err == nil { + var prior environmentBinding + if decodeEnvironmentJSON(raw, &prior) != nil || prior != want { + return errors.New("connect: Runtime belongs to a different Environment or history") + } + } else if errors.Is(err, os.ErrNotExist) { + // Unlabelled native state cannot safely be adopted by a new enrollment. + for _, native := range []string{"runtime", "sessions", "parsar-daemon/agent-sessions"} { + if _, e := os.Lstat(filepath.Join(root, native)); !errors.Is(e, os.ErrNotExist) { + return errors.New("connect: existing Runtime history has no Environment binding") + } + } + profiles, e := os.ReadDir(dir) + if e != nil { + return errors.New("connect: Runtime state directory unavailable") + } + for _, entry := range profiles { + if entry.IsDir() { + for _, name := range []string{"auth.json", "sessions.json", "runtime"} { + if _, e := os.Lstat(filepath.Join(dir, entry.Name(), name)); !errors.Is(e, os.ErrNotExist) { + return errors.New("connect: existing profile has no Environment binding") + } + } + } + } + data, _ := json.Marshal(want) + f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + 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)) + if e == nil { + e = f.Sync() + } + closeErr := f.Close() + if e != nil || closeErr != nil { + return errors.New("connect: Runtime binding write failed") + } + } else { + return errors.New("connect: Runtime binding unavailable") + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/parsar-daemon/internal/cli/connect_environment_test.go new file mode 100644 index 000000000..790903707 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_environment_test.go @@ -0,0 +1,255 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/google/uuid" +) + +func TestEnvironmentConnectionURL(t *testing.T) { + for _, valid := range []string{"wss://runtime.example/api/v1/agent-daemon/ws", "ws://127.0.0.1:123/api/v1/agent-daemon/ws", "ws://[::1]:123/api/v1/agent-daemon/ws"} { + base, err := environmentBase(valid) + if err != nil || !strings.HasSuffix(base, "/api/v1") { + t.Fatalf("valid URL rejected: %v", err) + } + } + for _, invalid := range []string{"ws://runtime.example/api/v1/agent-daemon/ws", "https://runtime.example/api/v1/agent-daemon/ws", "wss://secret@runtime.example/api/v1/agent-daemon/ws", "wss://runtime.example/api/v1/agent-daemon/ws?secret=value", "wss://runtime.example/api/v1/agent-daemon/ws#fragment", "wss://runtime.example/api/v1/agent-daemon/ws/", "wss://runtime.example/api%2fv1/agent-daemon/ws"} { + if _, err := environmentBase(invalid); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("invalid URL accepted or disclosed") + } + } +} + +func TestEnvironmentEnrollmentAndBootstrap(t *testing.T) { + environment := uuid.NewString() + want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} + var remote string + var enrolls, bootstraps int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer private-canary" { + t.Error("wrong authorization") + } + if r.Method != http.MethodPost { + t.Error("wrong method") + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + switch r.URL.Path { + case "/api/v1/agent-daemon/enroll": + enrolls++ + if len(body) != 1 || body["environment_id"] != environment { + t.Error("wrong enrollment body") + } + _ = json.NewEncoder(w).Encode(want) + case "/api/v1/agent-daemon/bootstrap": + bootstraps++ + if len(body) != 1 || body["device_id"] != want.DeviceID { + t.Error("wrong bootstrap body") + } + _ = json.NewEncoder(w).Encode(map[string]any{"device_id": want.DeviceID, "ws_url": remote, "heartbeat_seconds": 15}) + default: + t.Error("unexpected endpoint") + } + })) + defer server.Close() + remote = "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws" + base, _ := environmentBase(remote) + got, err := enrollEnvironment(context.Background(), environmentClient(), base, environment, "private-canary") + if err != nil || got != want { + t.Fatalf("enrollment: %v", err) + } + boot, err := environmentBootstrap(context.Background(), auth.Profile{ServerURL: base, RuntimeID: want.DeviceID, RunnerCredential: "private-canary"}, remote) + if err != nil || boot.WSURL != remote || enrolls != 1 || bootstraps != 1 { + t.Fatalf("bootstrap: %v", err) + } +} + +func TestEnvironmentTransportRejectsRedirectAndUntrustedBodies(t *testing.T) { + var leaked bool + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true })) + defer target.Close() + for _, status := range []int{301, 302, 307, 308, 401, 409, 503} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(status) + _, _ = w.Write([]byte("private-response-canary")) + })) + defer server.Close() + _, err := enrollEnvironment(context.Background(), environmentClient(), server.URL, uuid.NewString(), "private-canary") + if err == nil || strings.Contains(err.Error(), "canary") { + t.Fatal("enrollment error exposed body or accepted failure") + } + _, err = environmentBootstrap(context.Background(), auth.Profile{ServerURL: server.URL, RuntimeID: uuid.NewString(), RunnerCredential: "private-canary"}, "unused") + if err == nil || strings.Contains(err.Error(), "canary") { + t.Fatal("bootstrap error exposed body or accepted failure") + } + }) + } + if leaked { + t.Fatal("credential redirected") + } +} + +func TestEnvironmentBootstrapCannotChangeConnection(t *testing.T) { + device := uuid.NewString() + for _, response := range []map[string]string{{"device_id": uuid.NewString(), "ws_url": "wss://core/api/v1/agent-daemon/ws"}, {"device_id": device, "ws_url": "wss://other/api/v1/agent-daemon/ws"}, {"device_id": device}} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) })) + _, err := environmentBootstrap(context.Background(), auth.Profile{ServerURL: server.URL, RuntimeID: device, RunnerCredential: "canary"}, "wss://core/api/v1/agent-daemon/ws") + server.Close() + if err == nil { + t.Fatal("changed connection accepted") + } + } +} + +func TestExecutorCredentialFile(t *testing.T) { + environment := uuid.NewString() + path := filepath.Join(t.TempDir(), "key.json") + write := func(token string) { + raw, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": token, "environment_id": environment}) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + write("first-canary") + if token, err := executorCredential(path, environment); err != nil || token != "first-canary" { + t.Fatal("valid key rejected") + } + write("rotated-canary") + if token, err := executorCredential(path, environment); err != nil || token != "rotated-canary" { + t.Fatal("rotation not read") + } + if _, err := executorCredential(path, uuid.NewString()); err == nil { + t.Fatal("foreign restriction accepted") + } + link := filepath.Join(filepath.Dir(path), "link") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := executorCredential(link, environment); err == nil { + t.Fatal("symlink accepted") + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err := executorCredential(path, environment); err == nil { + t.Fatal("public credential accepted") + } +} + +func TestEnvironmentBindingPreservesIdentityAndHistory(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"} + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal(err) + } + history := filepath.Join(root, "parsar-daemon", "agent-sessions", "retained") + if err := os.MkdirAll(filepath.Dir(history), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(history, []byte("unchanged-history"), 0600); err != nil { + t.Fatal(err) + } + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*environmentBinding){func(b *environmentBinding) { b.Enrollment.SessionID = uuid.NewString() }, func(b *environmentBinding) { b.Enrollment.EnvironmentID = uuid.NewString() }, func(b *environmentBinding) { b.Enrollment.DeviceID = uuid.NewString() }, func(b *environmentBinding) { b.RemoteURL = "wss://other/api/v1/agent-daemon/ws" }} { + changed := want + mutate(&changed) + if err := saveEnvironmentBinding(root, changed); err == nil { + t.Fatal("identity overwritten") + } + } + if raw, _ := os.ReadFile(history); string(raw) != "unchanged-history" { + t.Fatal("history changed") + } + if err := os.Remove(filepath.Join(root, "parsar-daemon", "environment.json")); err != nil { + t.Fatal(err) + } + if err := saveEnvironmentBinding(root, want); err == nil { + t.Fatal("unlabelled history adopted") + } +} + +func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(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"} + if err := os.WriteFile(filepath.Join(root, "installed-bundle"), []byte("bundle"), 0600); err != nil { + t.Fatal(err) + } + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "parsar-daemon", "environment.json") + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if err := saveEnvironmentBinding(root, want); err == nil { + t.Fatal("public receipt accepted") + } +} + +func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PARSAR_HOME", root) + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal(err) + } + if err := checkEnvironmentTarget(want.RemoteURL, want.Enrollment.EnvironmentID); err != nil { + t.Fatal(err) + } + if err := checkEnvironmentTarget("wss://other/api/v1/agent-daemon/ws", want.Enrollment.EnvironmentID); err == nil { + t.Fatal("new credential recipient accepted") + } + if err := checkEnvironmentTarget(want.RemoteURL, uuid.NewString()); err == nil { + t.Fatal("new Environment accepted") + } +} + +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" }} { + bad := good + mutate(&bad) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(bad) })) + _, err := enrollEnvironment(context.Background(), environmentClient(), server.URL, environment, "secret") + server.Close() + if err == nil { + t.Fatal("invalid binding accepted") + } + } +} + +func TestEnvironmentConnectRejectsPairing(t *testing.T) { + t.Setenv(connectInlineURLEnv, "") + t.Setenv(connectInlineTokenEnv, "") + t.Setenv(connectInlineDeviceNameEnv, "") + rc := &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}} + err := runConnect(rc, []string{"--remote", "wss://core/api/v1/agent-daemon/ws", "--environment-id", uuid.NewString(), "--credential-file", "/unused", "--token", "private-pairing-canary"}) + if err == nil || strings.Contains(err.Error(), "private-pairing-canary") { + t.Fatal("pairing accepted or leaked") + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index fbfc7fd24..a4d4f8529 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -177,7 +177,7 @@ func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) } - if got.Codex.Capabilities.RemoteEnvironment || got.Codex.Capabilities.ExecutionControls != codex.SupportsTextVerbosity || got.ClaudeCode.Capabilities.ExecutionControls || got.OpenCode.Capabilities.ExecutionControls || !got.Codex.Capabilities.ToolObservations || !got.Codex.Capabilities.SubagentControl || got.Codex.Capabilities.TextVerbosity != codex.SupportsTextVerbosity || !got.Codex.Capabilities.WebSearchControl || !got.Codex.Capabilities.EnvironmentNone || !got.Codex.Capabilities.ToolItems || !got.Codex.Capabilities.MessageItems || !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { + if got.Codex.Capabilities.ExecutionControls != codex.SupportsTextVerbosity || got.ClaudeCode.Capabilities.ExecutionControls || got.OpenCode.Capabilities.ExecutionControls || !got.Codex.Capabilities.ToolObservations || !got.Codex.Capabilities.SubagentControl || got.Codex.Capabilities.TextVerbosity != codex.SupportsTextVerbosity || !got.Codex.Capabilities.WebSearchControl || !got.Codex.Capabilities.EnvironmentNone || !got.Codex.Capabilities.ToolItems || !got.Codex.Capabilities.MessageItems || !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) } if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { diff --git a/apps/parsar-daemon/internal/cli/mcp_test.go b/apps/parsar-daemon/internal/cli/mcp_test.go index 63ecb418a..1ff92f69e 100644 --- a/apps/parsar-daemon/internal/cli/mcp_test.go +++ b/apps/parsar-daemon/internal/cli/mcp_test.go @@ -28,7 +28,7 @@ func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { } } -func TestRemoteMCPDiscoveryRequiresPinnedNative(t *testing.T) { +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 } @@ -36,13 +36,13 @@ func TestRemoteMCPDiscoveryRequiresPinnedNative(t *testing.T) { if err != nil { t.Fatal(err) } - if got.Codex.Capabilities.MCPHTTPRequired != (version == "codex-cli 0.153.4") || got.Codex.Capabilities.MCPHTTPRemoteEnvironment != (version == "codex-cli 0.153.4") || got.Codex.Capabilities.MCPHTTPRemoteBearerAuth != (version == "codex-cli 0.153.4") { + if got.Codex.Capabilities.MCPHTTPRequired != (version == "codex-cli 0.153.4") { t.Fatal("unverified native combination advertised") } if got.Codex.Capabilities.NativeSessionRecovery != (version == "codex-cli 0.153.4") { t.Fatal("unverified native recovery advertised") } - if got.ClaudeCode.Capabilities.MCPHTTPRequired || got.OpenCode.Capabilities.MCPHTTPRequired || got.Pi.Capabilities.MCPHTTPRequired || got.ClaudeCode.Capabilities.MCPHTTPRemoteEnvironment || got.OpenCode.Capabilities.MCPHTTPRemoteEnvironment || got.Pi.Capabilities.MCPHTTPRemoteEnvironment || got.ClaudeCode.Capabilities.MCPHTTPRemoteBearerAuth || got.OpenCode.Capabilities.MCPHTTPRemoteBearerAuth || got.Pi.Capabilities.MCPHTTPRemoteBearerAuth { + if got.ClaudeCode.Capabilities.MCPHTTPRequired || got.OpenCode.Capabilities.MCPHTTPRequired || got.Pi.Capabilities.MCPHTTPRequired { t.Fatal("other engine advertised combination") } } diff --git a/apps/parsar-daemon/internal/cli/preparation_test.go b/apps/parsar-daemon/internal/cli/preparation_test.go index f04145749..5f5b54cfe 100644 --- a/apps/parsar-daemon/internal/cli/preparation_test.go +++ b/apps/parsar-daemon/internal/cli/preparation_test.go @@ -13,7 +13,7 @@ import ( func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { for _, supported := range []bool{false, true} { reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: supported}}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code"}, OpenCode: proto.SupportedAgentKind{Kind: "opencode"}, Pi: proto.SupportedAgentKind{Kind: "pi"}, MCode: proto.SupportedAgentKind{Kind: "mcode"}}, "http://unreachable.invalid") + registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: supported}}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code"}, OpenCode: proto.SupportedAgentKind{Kind: "opencode"}, Pi: proto.SupportedAgentKind{Kind: "pi"}, MCode: proto.SupportedAgentKind{Kind: "mcode"}}, "http://unreachable.invalid") _, err := reg.ResolvePreparation("codex") if (err == nil) != supported { t.Fatal("unverified native version advertised preparation") diff --git a/apps/parsar-daemon/internal/dispatch/environment.go b/apps/parsar-daemon/internal/dispatch/environment.go index bcc5acfd5..6f78d5a7e 100644 --- a/apps/parsar-daemon/internal/dispatch/environment.go +++ b/apps/parsar-daemon/internal/dispatch/environment.go @@ -7,17 +7,9 @@ import ( ) func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { - if req.LocalEnvironment != nil && (req.RemoteEnvironment != nil || req.DisableExecutionEnvironment || !caps.LocalEnvironment) { + if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment) { return errors.New("engine does not support this local Environment configuration") } - if req.RemoteEnvironment != nil { - if req.DisableExecutionEnvironment { - return errors.New("remote environment conflicts with execution environment none") - } - if !caps.RemoteEnvironment { - return errors.New("engine does not support a remote execution environment") - } - } if req.DisableExecutionEnvironment && !caps.EnvironmentNone { return errors.New("engine does not support execution environment none") } diff --git a/apps/parsar-daemon/internal/dispatch/environment_test.go b/apps/parsar-daemon/internal/dispatch/environment_test.go index 824dffd78..4111edf52 100644 --- a/apps/parsar-daemon/internal/dispatch/environment_test.go +++ b/apps/parsar-daemon/internal/dispatch/environment_test.go @@ -43,23 +43,25 @@ func TestNoEnvironmentUsesAvailableCapability(t *testing.T) { } } -func TestRemoteEnvironmentRequiresAvailableCapability(t *testing.T) { +func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { for _, mode := range []string{"unsupported", "unavailable", "none conflict", "supported"} { t.Run(mode, func(t *testing.T) { - h := newHarness(t) + h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) called := false h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: mode != "unavailable", - Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: mode != "unsupported"}}, + Capabilities: proto.AgentKindCapabilities{LocalEnvironment: mode != "unsupported"}}, func(_ context.Context, req proto.PromptRequestPayload, _ chan<- proto.Envelope) (agent.Session, error) { called = true - if req.RemoteEnvironment == nil || req.RemoteEnvironment.ID != "environment-test" { - t.Error("remote descriptor lost before factory") + if req.LocalEnvironment == nil || req.LocalEnvironment.ID != preparationEnvironmentID { + t.Error("local descriptor lost before factory") } return nil, errors.New("controlled factory stop") }) - req := proto.PromptRequestPayload{AgentKind: "codex", RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment-test"}, DisableExecutionEnvironment: mode == "none conflict"} - _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "remote", req)) + req := preparationRequest().Configuration + req.AgentKind = "codex" + req.DisableExecutionEnvironment = mode == "none conflict" + _ = h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "local", req)) if called != (mode == "supported") { t.Fatalf("unexpected factory call for %s", mode) } diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index b4827e506..dcc118460 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "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" @@ -70,3 +71,22 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing t.Fatal("read-only operation reached native execution") } } + +func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.WorkspaceReadResultPayload { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, env := range sender.snapshot() { + if env.Type == proto.TypeWorkspaceReadResult && env.ID == id { + var result proto.WorkspaceReadResultPayload + if env.DecodePayload(&result) != nil { + t.Fatal("invalid read result") + } + return result + } + } + time.Sleep(time.Millisecond) + } + t.Fatal("read result missing", id) + return proto.WorkspaceReadResultPayload{} +} diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http.go b/apps/parsar-daemon/internal/dispatch/mcp_http.go index 5d7548075..afb382481 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http.go @@ -12,11 +12,11 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if req.MCPHTTPServers == nil { return nil } - if req.RemoteEnvironment != nil && (!caps.MCPHTTPTools || !caps.MCPHTTPRemoteEnvironment) { - return errors.New("engine does not support service-side HTTP MCP with a remote environment") + if req.LocalEnvironment != nil { + return errors.New("service-side HTTP MCP is not supported with a local Environment") } for _, server := range *req.MCPHTTPServers { - if server.Required && (!caps.MCPHTTPTools || !caps.MCPHTTPRequired || req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil)) { + if server.Required && (!caps.MCPHTTPTools || !caps.MCPHTTPRequired || !req.DisableExecutionEnvironment) { return errors.New("engine does not support required service-side HTTP MCP initialization") } if server.BearerToken == nil { @@ -25,14 +25,10 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if !caps.MCPHTTPTools || !caps.MCPHTTPBearerAuth { return errors.New("engine does not support authenticated HTTP MCP") } - if req.DisableExecutionEnvironment == (req.RemoteEnvironment != nil) { + if !req.DisableExecutionEnvironment { return errors.New("authenticated HTTP MCP requires a supported service-side environment") } - if req.RemoteEnvironment != nil { - if !caps.RemoteEnvironment || !caps.MCPHTTPRemoteBearerAuth { - return errors.New("engine does not support authenticated HTTP MCP with a remote environment") - } - } else if !caps.EnvironmentNone { + if !caps.EnvironmentNone { return errors.New("engine does not support authenticated HTTP MCP with environment:none") } endpoint, err := url.Parse(server.ServerURL) diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go index a5a1177db..127a7f96d 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -13,7 +13,7 @@ import ( ) func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { - for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "claude remote", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "remote", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { + for _, mode := range []string{"supported", "claude", "claude old peer", "claude local", "no bearer capability", "no MCP capability", "unavailable", "no none capability", "local", "other engine", "HTTP", "credential-free", "product", "required", "required old peer", "optional old peer"} { t.Run(mode, func(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) @@ -22,16 +22,12 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} caps := proto.AgentKindCapabilities{EnvironmentNone: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true} switch mode { - case "claude", "claude old peer", "claude local", "claude remote": + case "claude", "claude old peer", "claude local": req.AgentKind = "claude_sdk" caps.MCPHTTPBearerAuth = mode != "claude old peer" - if mode == "claude local" || mode == "claude remote" { + if mode == "claude local" { req.DisableExecutionEnvironment = false } - if mode == "claude remote" { - req.RemoteEnvironment = &proto.RemoteEnvironment{ID: "remote"} - caps.RemoteEnvironment, caps.MCPHTTPRemoteEnvironment, caps.MCPHTTPRemoteBearerAuth = true, true, true - } case "required", "required old peer", "optional old peer": servers[0].Required = mode != "optional old peer" servers[0].BearerToken = nil @@ -44,10 +40,6 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { caps.EnvironmentNone = false case "local": req.DisableExecutionEnvironment = false - case "remote": - req.DisableExecutionEnvironment = false - req.RemoteEnvironment = &proto.RemoteEnvironment{ID: "remote"} - caps.RemoteEnvironment = true case "other engine": req.AgentKind = "other" case "HTTP": @@ -72,7 +64,7 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { return nil, errors.New("controlled factory stop") }) err := h.router.Handle(t.Context(), mustEnv(t, proto.TypePromptRequest, "mcp-bearer", req)) - if called != (mode == "supported" || mode == "claude" || mode == "claude remote" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { + if called != (mode == "supported" || mode == "claude" || mode == "other engine" || mode == "credential-free" || mode == "product" || mode == "required" || mode == "optional old peer") { t.Fatal("wrong factory admission") } frames := h.sender.snapshot() @@ -84,66 +76,38 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { } } -func TestRemoteMCPRejectsBeforePreparationFactory(t *testing.T) { - for _, mode := range []string{"supported", "old peer", "no MCP", "no remote", "bearer old peer", "bearer supported", "bearer no general auth", "bearer none conflict", "bearer HTTP", "other engine", "empty declaration", "no declaration", "required", "required old peer"} { +func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { + for _, mode := range []string{"anonymous", "bearer", "required", "empty declaration", "no declaration"} { t.Run(mode, func(t *testing.T) { - h := newHarness(t) + h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} req := preparationRequest() - req.Configuration.AgentKind = "codex" + servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} req.Configuration.MCPHTTPServers = &servers - caps := proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, EnvironmentNone: true, MCPHTTPBearerAuth: true} - switch mode { - case "required", "required old peer": - servers[0].Required = true - caps.MCPHTTPRequired = mode == "required" - case "old peer": - caps.MCPHTTPRemoteEnvironment = false - case "no MCP": - caps.MCPHTTPTools = false - case "no remote": - caps.RemoteEnvironment = false - case "other engine": - req.Configuration.AgentKind = "other" - case "bearer old peer", "bearer supported", "bearer no general auth", "bearer none conflict", "bearer HTTP": + if mode == "bearer" { token := "synthetic-private-token" servers[0].BearerToken = &token - caps.MCPHTTPRemoteBearerAuth = mode != "bearer old peer" - caps.EnvironmentNone = false - if mode == "bearer no general auth" { - caps.MCPHTTPBearerAuth = false - } - if mode == "bearer none conflict" { - req.Configuration.DisableExecutionEnvironment = true - } - if mode == "bearer HTTP" { - servers[0].ServerURL = "http://tools.example/mcp" - } - case "empty declaration": + } + if mode == "required" { + servers[0].Required = true + } + if mode == "empty declaration" { servers = []proto.MCPHTTPServer{} - caps.MCPHTTPRemoteEnvironment = false - case "no declaration": + } + if mode == "no declaration" { req.Configuration.MCPHTTPServers = nil - caps.MCPHTTPRemoteEnvironment = false } entered := make(chan struct{}, 1) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: req.Configuration.AgentKind, Available: true, Capabilities: caps}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { t.Error("ordinary factory called") return nil, errors.New("unexpected") }) - h.reg.RegisterPreparation(req.Configuration.AgentKind, false, func(_ context.Context, got proto.PromptRequestPayload) (agent.Prepared, error) { - if mode == "required" && !(*got.MCPHTTPServers)[0].Required { - t.Error("required initialization lost before preparation") - } - if mode == "bearer supported" && (got.MCPHTTPServers == nil || (*got.MCPHTTPServers)[0].BearerToken == nil || *(*got.MCPHTTPServers)[0].BearerToken != "synthetic-private-token") { - t.Error("remote bearer lost before preparation") - } + h.reg.RegisterPreparation("prepared", false, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { entered <- struct{}{} return nil, errors.New("controlled stop") }) - err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "remote-mcp", req)) - allowed := mode == "supported" || mode == "other engine" || mode == "no declaration" || mode == "bearer supported" || mode == "required" + err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local-mcp", req)) + allowed := mode == "no declaration" if (err == nil) != allowed { t.Fatal("wrong preparation admission", err) } @@ -153,7 +117,7 @@ func TestRemoteMCPRejectsBeforePreparationFactory(t *testing.T) { case <-time.After(time.Second): t.Fatal("factory not called") } - waitPreparationStatus(t, h.sender, "remote-mcp", "failed", "") + waitPreparationStatus(t, h.sender, "local-mcp", "failed", "") } else { select { case <-entered: diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go index bfe5bb923..3d02fdfb6 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "sync" "sync/atomic" "testing" @@ -11,6 +13,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/agentdaemon/proto" ) @@ -64,18 +67,61 @@ func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } +const preparationEnvironmentID = "11111111-1111-4111-8111-111111111111" +const preparationSessionID = "22222222-2222-4222-8222-222222222222" + +func preparationWorkspace(t *testing.T) *localworkspace.Binding { + t.Helper() + 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 { + t.Fatal(err) + } + for name, value := range map[string]string{ + "PARSAR_RUNTIME_ENVIRONMENT_ID": preparationEnvironmentID, + "PARSAR_RUNTIME_SESSION_ID": preparationSessionID, + "PARSAR_RUNTIME_WORKSPACE": t.TempDir(), + "PARSAR_RUNTIME_DIRECTORY_HELPER": helper, + "PARSAR_RUNTIME_NETWORK_ACCESS": "enabled", + "PARSAR_RUNTIME_ALLOWED_DOMAINS": "", + "PARSAR_RUNTIME_WRITE_HELPER": "", + "PARSAR_RUNTIME_EXPORT_HELPER": "", + "PARSAR_RUNTIME_STAGING": "", + } { + t.Setenv(name, value) + } + binding, err := localworkspace.Load() + if err != nil { + t.Fatal(err) + } + return binding +} + +func localPreparationHarness(t *testing.T) *harness { + t.Helper() + h := newHarness(t) + if err := h.router.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + var err error + h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, LocalWorkspace: preparationWorkspace(t)}) + if err != nil { + t.Fatal(err) + } + return h +} + func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "execution-session", StrictResume: true, ReleaseOnCompletion: true, RemoteEnvironment: &proto.RemoteEnvironment{ID: "environment"}}} + return proto.ExecutionPreparePayload{Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled"}}} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { t.Helper() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("ordinary Factory must not be used for preparation") }) reg.RegisterPreparation("prepared", true, factory) - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { t.Fatal(err) } @@ -382,14 +428,14 @@ func TestPreparationCapacityIncludesClosingResources(t *testing.T) { func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ - "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, - "input": func(p *proto.PromptRequestPayload) { p.Prompt = "input" }, - "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, - "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, - "attachment": func(p *proto.PromptRequestPayload) { p.Attachments = []proto.PromptAttachment{{Kind: "image"}} }, - "local fallback": func(p *proto.PromptRequestPayload) { p.RemoteEnvironment = nil }, - "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, - "release": func(p *proto.PromptRequestPayload) { p.ReleaseOnCompletion = false }, + "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, + "input": func(p *proto.PromptRequestPayload) { p.Prompt = "input" }, + "conversation": func(p *proto.PromptRequestPayload) { p.ConversationID = "product" }, + "authoring": func(p *proto.PromptRequestPayload) { p.WorkspaceAuthoring = true }, + "attachment": func(p *proto.PromptRequestPayload) { p.Attachments = []proto.PromptAttachment{{Kind: "image"}} }, + "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_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go index 2500004d5..d7c135190 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go @@ -193,7 +193,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if ack := lastSteeringAck(t, sender.recSender, "run", "publication-steering"); ack.ErrorCode != "not_ready" { t.Fatalf("pre-publication steering = %+v", ack) } - read := proto.WorkspaceReadPayload{RunID: "run", EnvironmentID: "environment", Path: "file", MaxBytes: 1} + read := proto.WorkspaceReadPayload{RunID: "run", EnvironmentID: preparationEnvironmentID, Path: "file", MaxBytes: 1} if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "publication-read", read)); err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go index 6dad38d69..e31816922 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -3,7 +3,6 @@ package dispatch_test import ( "context" "fmt" - "sync/atomic" "testing" "time" @@ -11,37 +10,16 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -type directoryTestReader struct{ calls atomic.Int32 } - -func (r *directoryTestReader) ListWorkspaceDirectory(_ context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { - if path != "" || limit != 2 { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid - } - r.calls.Add(1) - size := int64(3) - return agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{{Name: "file", Kind: "file", SizeBytes: &size}}, Truncated: true}, nil -} - -type directoryPreparation struct { - *controlledPreparation - *directoryTestReader -} -type directorySession struct { - *fakeSession - *directoryTestReader -} - func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { sender := &recSender{} - reader := &directoryTestReader{} - p := &directoryPreparation{&controlledPreparation{closed: make(chan struct{})}, reader} + p := &controlledPreparation{closed: make(chan struct{})} p.start = func(ctx context.Context, _ string, _ string, out chan<- proto.Envelope) (agent.Session, error) { - return &directorySession{&fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, reader}, nil + return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: "environment", MaxEntries: 2} + request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 2} for _, phase := range []string{"idle", "active"} { _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) result := waitWorkspaceRead(t, sender, phase) @@ -65,10 +43,10 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { } } for index, bad := range []proto.WorkspaceReadPayload{ - {Operation: "directory", RunID: "run", EnvironmentID: "environment", MaxEntries: 2, MaxBytes: 1}, - {Operation: "directory", RunID: "run", EnvironmentID: "environment", MaxEntries: proto.WorkspaceDirectoryMaxEntries + 1}, - {Operation: "directory", Handle: ready.Handle, RunID: "run", EnvironmentID: "environment", MaxEntries: 2}, - {Operation: "recursive", RunID: "run", EnvironmentID: "environment", MaxEntries: 2}, + {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2, MaxBytes: 1}, + {Operation: "directory", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: proto.WorkspaceDirectoryMaxEntries + 1}, + {Operation: "directory", Handle: ready.Handle, RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, + {Operation: "recursive", RunID: "run", EnvironmentID: preparationEnvironmentID, MaxEntries: 2}, } { id := fmt.Sprintf("invalid-%d", index) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, id, bad)) @@ -76,7 +54,4 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { t.Fatal("malformed directory control reached a resource", got) } } - if reader.calls.Load() != 2 { - t.Fatal("wrong owner was observed", reader.calls.Load()) - } } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go index 5a53d169e..8989ecb44 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_preparation_status_test.go @@ -3,6 +3,7 @@ package dispatch import ( "context" "errors" + "sync" "sync/atomic" "testing" "time" @@ -89,3 +90,47 @@ func TestReadPreparationRetryCannotPublishStaleRelease(t *testing.T) { t.Fatal("confirmed cleanup failure was suppressed") } } + +// Local read-only preparation uses no native factory. Keep the shared close +// settlement regression at its owner boundary instead of a retired remote fixture. +type blockingWorkspacePreparation struct { + agent.Prepared + entered, release chan struct{} +} + +func (p *blockingWorkspacePreparation) Close() error { + close(p.entered) + <-p.release + return nil +} + +func TestReadPreparationReleaseWaitsForClose(t *testing.T) { + sender := make(workspaceStatusSender, 4) + prepared := &blockingWorkspacePreparation{entered: make(chan struct{}), release: make(chan struct{})} + var release sync.Once + defer release.Do(func() { close(prepared.release) }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + timer := time.NewTimer(time.Hour) + defer timer.Stop() + r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: obslog.Bg()} + p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, + ctx: ctx, cancel: cancel, timer: timer, + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} + r.releasePreparation(p, "released", "", true, true) + select { + case <-prepared.entered: + case <-time.After(time.Second): + t.Fatal("close did not start") + } + r.publishPreparation(p, p.status) + if len(sender) != 0 || !p.owns { + t.Fatal("release acknowledged before close settled") + } + release.Do(func() { close(prepared.release) }) + r.shutdownWG.Wait() + var status proto.PreparationStatusPayload + if p.owns || len(sender) != 1 || (<-sender).DecodePayload(&status) != nil || status.State != "released" { + t.Fatal("settled close did not release ownership and publish status") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go b/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go deleted file mode 100644 index 49837ad78..000000000 --- a/apps/parsar-daemon/internal/dispatch/workspace_preparation_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package dispatch_test - -import ( - "context" - "errors" - "fmt" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestWorkspaceReadPreparationRejectsStartAndWaitsForClose(t *testing.T) { - sender := &recSender{} - entered, release := make(chan struct{}), make(chan struct{}) - p := &controlledPreparation{closed: make(chan struct{}), closeHook: func() { close(entered); <-release }} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - request := preparationRequest() - request.Configuration.WorkspaceReadOnly = true - prepare := mustEnv(t, proto.TypeExecutionPrepare, "read", request) - if err := r.Handle(t.Context(), prepare); err != nil { - t.Fatal(err) - } - ready := waitPreparationStatus(t, sender, "read", "ready", "") - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "read", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "work"})); err == nil || p.starts.Load() != 0 { - t.Fatal("read owner admitted a Run") - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - <-entered - // An idempotent prepare retry must not expose a premature terminal status. - if err := r.Handle(t.Context(), prepare); err != nil { - t.Fatal(err) - } - for _, envelope := range sender.snapshot() { - var status proto.PreparationStatusPayload - if envelope.DecodePayload(&status) == nil && status.State == "released" { - t.Fatal("release acknowledged before native close") - } - } - close(release) - waitPreparationStatus(t, sender, "read", "released", "") - if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { - t.Fatal("settled release retained ownership") - } -} - -func TestReadPreparationConstructionFailureRetainsCapacity(t *testing.T) { - var settled atomic.Bool - sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return &retryablePreparation{close: func(int32) error { - if !settled.Load() { - return errors.New("unreaped child") - } - return nil - }}, errors.New("initialization failed") - }) - defer settled.Store(true) - request := preparationRequest() - request.Configuration.WorkspaceReadOnly = true - for i := range 4 { - id := fmt.Sprint("failed-read-", i) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, request)); err != nil { - t.Fatal(err) - } - failed := waitPreparationStatus(t, sender, id, "failed", "") - if owned, _ := r.PreparationOwnershipForTest(failed.Handle); !owned || failed.ErrorCode != "cleanup_unconfirmed" { - t.Fatal("failed constructor did not retain cleanup ownership") - } - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "fifth", request)) - status := waitPreparationStatus(t, sender, "fifth", "rejected", "") - if status.ErrorCode != "preparation_capacity" { - t.Fatal("unreaped readers exceeded preparation capacity") - } -} - -func TestWorkspaceReadPreparationRetainsFailedCleanup(t *testing.T) { - sender := &recSender{} - p := &retryablePreparation{close: func(call int32) error { - if call == 1 { - return errors.New("controlled cleanup failure") - } - return nil - }} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - request := preparationRequest() - request.Configuration.WorkspaceReadOnly = true - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "read", request)); err != nil { - t.Fatal(err) - } - ready := waitPreparationStatus(t, sender, "read", "ready", "") - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - failed := waitPreparationStatus(t, sender, "read", "failed", "") - if failed.ErrorCode != "cleanup_unconfirmed" { - t.Fatal("cleanup failure hidden") - } - if owned, _ := r.PreparationOwnershipForTest(ready.Handle); !owned { - t.Fatal("uncertain cleanup discarded ownership") - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "read", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - released := waitPreparationStatus(t, sender, "read", "released", "") - if released.ErrorCode != "" || released.Revision <= failed.Revision { - t.Fatal("successful cleanup retry did not report confirmation") - } - if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { - t.Fatal("confirmed retry retained ownership") - } -} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read_test.go b/apps/parsar-daemon/internal/dispatch/workspace_read_test.go deleted file mode 100644 index 06f5413d7..000000000 --- a/apps/parsar-daemon/internal/dispatch/workspace_read_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package dispatch_test - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "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 workspaceTestReader struct { - read func(context.Context, string, int) (agent.WorkspaceReadResult, error) -} - -func (r *workspaceTestReader) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { - return r.read(ctx, path, limit) -} - -type readablePreparation struct { - *controlledPreparation - *workspaceTestReader -} -type readableSession struct { - *fakeSession - *workspaceTestReader -} - -func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.WorkspaceReadResultPayload { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - for _, env := range sender.snapshot() { - if env.Type == proto.TypeWorkspaceReadResult && env.ID == id { - var result proto.WorkspaceReadResultPayload - if env.DecodePayload(&result) != nil { - t.Fatal("invalid read result") - } - return result - } - } - time.Sleep(time.Millisecond) - } - t.Fatal("read result missing", id) - return proto.WorkspaceReadResultPayload{} -} - -func TestWorkspaceReadUsesPreparationThenTransferredRun(t *testing.T) { - sender := &recSender{} - var calls atomic.Int32 - reader := &workspaceTestReader{read: func(_ context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { - calls.Add(1) - if path != "file" || limit != 3 { - return agent.WorkspaceReadResult{}, errors.New("request changed") - } - return agent.WorkspaceReadResult{Data: []byte{0, 1, 255}, Truncated: true}, nil - }} - p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: reader} - p.start = func(ctx context.Context, _ string, _ string, out chan<- proto.Envelope) (agent.Session, error) { - return &readableSession{&fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, reader}, nil - } - 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, "prepare", preparationRequest())) - ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "idle", request)) - if result := waitWorkspaceRead(t, sender, "idle"); result.Outcome != "completed" || !result.CloseAcknowledged || !result.Truncated { - t.Fatal(result) - } - bad := request - bad.EnvironmentID = "another-environment" - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", bad)) - if result := waitWorkspaceRead(t, sender, "foreign"); result.ErrorCode != "resource_unavailable" { - t.Fatal(result) - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Prompt: "start"})) - waitPreparationStatus(t, sender, "prepare", "started", "") - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "old-handle", request)) - if result := waitWorkspaceRead(t, sender, "old-handle"); result.Outcome != "rejected" { - t.Fatal(result) - } - request.Handle, request.RunID = "", "run" - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "active", request)) - if result := waitWorkspaceRead(t, sender, "active"); result.Outcome != "completed" { - t.Fatal(result) - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{})) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "cancelled", request)) - if result := waitWorkspaceRead(t, sender, "cancelled"); result.Outcome != "rejected" { - t.Fatal(result) - } - if calls.Load() != 2 { - t.Fatal("rejected reads reached adapter", calls.Load()) - } -} - -func TestWorkspaceReadWaitSurvivesObserverAndResourceRelease(t *testing.T) { - sender := &recSender{} - entered, settle := make(chan context.Context, 1), make(chan struct{}) - p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(ctx context.Context, _ string, _ int) (agent.WorkspaceReadResult, error) { - entered <- ctx - <-settle - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUncertain - }}} - 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, "prepare", preparationRequest())) - ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} - observer, cancel := context.WithCancel(t.Context()) - _ = r.Handle(observer, mustEnv(t, proto.TypeWorkspaceRead, "read", request)) - operation := <-entered - cancel() - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "read", request)); err == nil { - t.Fatal("duplicate pending operation accepted") - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "prepare", proto.ExecutionReleasePayload{Handle: ready.Handle})) - waitPreparationClosed(t, p.controlledPreparation) - if operation.Err() != nil { - t.Fatal("observer/release discarded accepted waiter") - } - short, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) - if err := r.Shutdown(short); !errors.Is(err, context.DeadlineExceeded) { - t.Fatal("shutdown lost pending read", err) - } - stop() - close(settle) - if result := waitWorkspaceRead(t, sender, "read"); result.Outcome != "unknown" || len(result.Data) != 0 || result.CloseAcknowledged { - t.Fatal(result) - } - if err := r.Shutdown(t.Context()); err != nil { - t.Fatal(err) - } -} - -func TestWorkspaceReadCapacityIsConnectionBounded(t *testing.T) { - sender := &recSender{} - entered, settle := make(chan struct{}, 4), make(chan struct{}) - p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(context.Context, string, int) (agent.WorkspaceReadResult, error) { - entered <- struct{}{} - <-settle - return agent.WorkspaceReadResult{}, nil - }}} - 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, "prepare", preparationRequest())) - ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: 3} - for i := 0; i < 4; i++ { - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, fmt.Sprint(i), request)) - <-entered - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "excess", request)) - if result := waitWorkspaceRead(t, sender, "excess"); result.ErrorCode != "read_capacity" { - t.Fatal(result) - } - close(settle) - for i := 0; i < 4; i++ { - if result := waitWorkspaceRead(t, sender, fmt.Sprint(i)); result.Outcome != "completed" { - t.Fatal(result) - } - } -} - -func TestWorkspaceReadBoundsRequestsAndEchoedMetadata(t *testing.T) { - sender := &recSender{} - var calls atomic.Int32 - p := &readablePreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, workspaceTestReader: &workspaceTestReader{read: func(context.Context, string, int) (agent.WorkspaceReadResult, error) { - calls.Add(1) - return agent.WorkspaceReadResult{Data: bytes.Repeat([]byte{255}, proto.WorkspaceReadMaxBytes)}, nil - }}} - 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, "prepare", preparationRequest())) - ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: "environment", Path: "file", MaxBytes: proto.WorkspaceReadMaxBytes} - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, strings.Repeat("x", 3<<20), request)); err == nil { - t.Fatal("oversized ID accepted") - } - bad := request - bad.Path = strings.Repeat("x", proto.WorkspaceReadMaxRequestBytes) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "oversized", bad)) - if result := waitWorkspaceRead(t, sender, "oversized"); result.ErrorCode != "invalid_request" { - t.Fatal(result.Outcome, result.ErrorCode) - } - if calls.Load() != 0 { - t.Fatal("invalid control reached adapter") - } - id := strings.Repeat("\x00", proto.WorkspaceReadMaxIDBytes) - env := mustEnv(t, proto.TypeWorkspaceRead, id, request) - env.Trace = strings.Repeat("x", 3<<20) - if err := r.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } - if result := waitWorkspaceRead(t, sender, id); result.Outcome != "completed" { - t.Fatal(result.Outcome) - } - for _, reply := range sender.snapshot() { - if reply.Type != proto.TypeWorkspaceReadResult { - continue - } - encoded, err := json.Marshal(reply) - if err != nil || len(encoded) >= 4<<20 { - t.Fatal("oversized response", len(encoded), err) - } - if reply.Trace != "" { - t.Fatal("oversized trace echoed") - } - } - if calls.Load() != 1 { - t.Fatal("unexpected read count", calls.Load()) - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 8edbdf8ea..3ed9a6f43 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -89,7 +89,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, nil } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || - r.RemoteEnvironment != nil || r.DisableExecutionEnvironment || r.WorkDir != "" || + r.DisableExecutionEnvironment || r.WorkDir != "" || r.ConversationID != "" || r.WorkspaceAuthoring || len(r.Attachments) != 0 || !r.StrictResume || !r.ReleaseOnCompletion { return r, errors.New("request does not match the dedicated local Environment") } diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 1f9983215..062d45a95 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -38,7 +38,6 @@ func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { }, "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "agents-api-" + uuid.NewString() }, "path override": func(r *proto.PromptRequestPayload) { r.WorkDir = b.workspace }, - "remote": func(r *proto.PromptRequestPayload) { r.RemoteEnvironment = &proto.RemoteEnvironment{ID: "other"} }, "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, "product authoring": func(r *proto.PromptRequestPayload) { r.WorkspaceAuthoring = true }, "non-strict resume": func(r *proto.PromptRequestPayload) { r.StrictResume = false }, diff --git a/apps/parsar-daemon/internal/transport/bootstrap.go b/apps/parsar-daemon/internal/transport/bootstrap.go index 14b4b68c9..abc97dcdc 100644 --- a/apps/parsar-daemon/internal/transport/bootstrap.go +++ b/apps/parsar-daemon/internal/transport/bootstrap.go @@ -44,6 +44,11 @@ func (b BootstrapResponse) HeartbeatInterval() time.Duration { // Bootstrap calls POST /agent-daemon/bootstrap with the // device's runner_credential as a bearer token. func Bootstrap(ctx context.Context, serverURL, deviceID, credential, daemonVersion string) (*BootstrapResponse, error) { + return BootstrapWithClient(ctx, http.DefaultClient, serverURL, deviceID, credential, daemonVersion) +} + +// BootstrapWithClient lets a connection entry point enforce its redirect policy. +func BootstrapWithClient(ctx context.Context, client *http.Client, serverURL, deviceID, credential, daemonVersion string) (*BootstrapResponse, error) { if strings.TrimSpace(serverURL) == "" { return nil, fmt.Errorf("transport.Bootstrap: serverURL required") } @@ -70,7 +75,7 @@ func Bootstrap(ctx context.Context, serverURL, deviceID, credential, daemonVersi if daemonVersion != "" { req.Header.Set("User-Agent", "parsar-daemon/"+daemonVersion) } - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("transport.Bootstrap: post: %w", err) } diff --git a/apps/parsar-daemon/internal/transport/bootstrap_test.go b/apps/parsar-daemon/internal/transport/bootstrap_test.go index 8dc951999..c6d936246 100644 --- a/apps/parsar-daemon/internal/transport/bootstrap_test.go +++ b/apps/parsar-daemon/internal/transport/bootstrap_test.go @@ -35,7 +35,7 @@ func TestBootstrapSendsBearerAndDeviceID(t *testing.T) { "workspace_id": "ws_xyz", "ws_url": "wss://example/agent-daemon/ws", "heartbeat_seconds": 15, - "protocol_version": "0.2.0", + "protocol_version": "0.3.0", }) })) defer srv.Close() diff --git a/apps/parsar-daemon/internal/transport/ws.go b/apps/parsar-daemon/internal/transport/ws.go index fdcd686d1..110ec827d 100644 --- a/apps/parsar-daemon/internal/transport/ws.go +++ b/apps/parsar-daemon/internal/transport/ws.go @@ -29,8 +29,7 @@ type DialOptions struct { DeviceID string // Credential is the bearer compared against runner_credential_hash. - // Sent as the token query param (not as a header) because some - // HTTP middleware strips Authorization on upgrade requests. + // Sent only as an Authorization bearer header. Credential string // DaemonVersion is the X.Y.Z string used for @@ -85,7 +84,6 @@ func Dial(ctx context.Context, opts DialOptions) (*Conn, error) { } dialURL, err := withQueryParams(opts.WSURL, map[string]string{ "device_id": opts.DeviceID, - "token": opts.Credential, "version": opts.DaemonVersion, }) if err != nil { @@ -96,7 +94,12 @@ func Dial(ctx context.Context, opts DialOptions) (*Conn, error) { dialCtx, cancel := context.WithTimeout(ctx, opts.HandshakeTimeout) defer cancel() - wsConn, resp, err := dialer.DialContext(dialCtx, dialURL, opts.HTTPHeader) + headers := opts.HTTPHeader.Clone() + if headers == nil { + headers = make(http.Header) + } + headers.Set("Authorization", "Bearer "+opts.Credential) + wsConn, resp, err := dialer.DialContext(dialCtx, dialURL, headers) if err != nil { // 401/403/426 are operator-fixable and MUST NOT be retried — // the gateway will keep rejecting until credential / device / @@ -105,11 +108,15 @@ func Dial(ctx context.Context, opts DialOptions) (*Conn, error) { if resp != nil { switch resp.StatusCode { case http.StatusUnauthorized, http.StatusForbidden, http.StatusUpgradeRequired: - return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with %s: %w: %w", resp.Status, err, ErrPermanent) + return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with HTTP %d: %w", resp.StatusCode, ErrPermanent) } - return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with %s: %w", resp.Status, err) + return nil, fmt.Errorf("transport.Dial: ws upgrade rejected with HTTP %d", resp.StatusCode) + } + if dialCtx.Err() != nil { + return nil, fmt.Errorf("transport.Dial: ws upgrade: %w", dialCtx.Err()) } - return nil, fmt.Errorf("transport.Dial: ws upgrade: %w", err) + // Upgrade errors can include peer-controlled text; never log credentials. + return nil, fmt.Errorf("transport.Dial: ws upgrade failed") } // Bound a single inbound frame so a misbehaving server can't OOM // us. Matches the gateway's 4 MiB outbound ceiling. @@ -354,13 +361,14 @@ func isPermanentClose(err error) bool { return websocket.IsCloseError(err, CloseRuntimeDeleted) } -// withQueryParams appends params, preserving any existing query string. +// withQueryParams appends non-secret parameters and removes legacy credentials. func withQueryParams(raw string, params map[string]string) (string, error) { u, err := url.Parse(raw) if err != nil { - return "", fmt.Errorf("transport: parse ws url %q: %w", raw, err) + return "", fmt.Errorf("transport: invalid ws url") } q := u.Query() + q.Del("token") for k, v := range params { q.Set(k, v) } diff --git a/apps/parsar-daemon/internal/transport/ws_test.go b/apps/parsar-daemon/internal/transport/ws_test.go index d5e013292..21b3e2989 100644 --- a/apps/parsar-daemon/internal/transport/ws_test.go +++ b/apps/parsar-daemon/internal/transport/ws_test.go @@ -26,6 +26,7 @@ type fakeGateway struct { mu sync.Mutex dialURL string + dialAuth string frames []proto.Envelope wantAuth bool @@ -42,6 +43,7 @@ func newFakeGateway() *fakeGateway { func (g *fakeGateway) handler(w http.ResponseWriter, r *http.Request) { g.mu.Lock() g.dialURL = r.URL.String() + g.dialAuth = r.Header.Get("Authorization") g.mu.Unlock() conn, err := g.upgrader.Upgrade(w, r, nil) @@ -84,13 +86,15 @@ func (g *fakeGateway) recordedDialURL() string { // httpToWS rewrites httptest server URL into the ws:// equivalent. func httpToWS(s string) string { return "ws" + strings.TrimPrefix(s, "http") } -func TestDialPassesAuthInQueryParams(t *testing.T) { +func TestDialPassesAuthOnlyInHeader(t *testing.T) { gw := newFakeGateway() srv := httptest.NewServer(http.HandlerFunc(gw.handler)) defer srv.Close() + headers := http.Header{"Authorization": {"Bearer obsolete"}, "X-Test": {"preserved"}} conn, err := transport.Dial(context.Background(), transport.DialOptions{ - WSURL: httpToWS(srv.URL) + "/agent-daemon/ws", + WSURL: httpToWS(srv.URL) + "/agent-daemon/ws?token=legacy", + HTTPHeader: headers, DeviceID: "rt_abc", Credential: "shh-secret", DaemonVersion: "0.1.0", @@ -104,8 +108,17 @@ func TestDialPassesAuthInQueryParams(t *testing.T) { if !strings.Contains(dialURL, "device_id=rt_abc") { t.Errorf("dial URL %q missing device_id", dialURL) } - if !strings.Contains(dialURL, "token=shh-secret") { - t.Errorf("dial URL %q missing token", dialURL) + if strings.Contains(dialURL, "token") || strings.Contains(dialURL, "shh-secret") { + t.Errorf("dial URL contains credentials") + } + gw.mu.Lock() + auth := gw.dialAuth + gw.mu.Unlock() + if auth != "Bearer shh-secret" { + t.Error("missing credential authorization header") + } + if headers.Get("Authorization") != "Bearer obsolete" { + t.Error("Dial mutated caller headers") } if !strings.Contains(dialURL, "version=0.1.0") { t.Errorf("dial URL %q missing version", dialURL) @@ -424,3 +437,25 @@ func TestSendRespectsContextCancel(t *testing.T) { t.Fatalf("Send with cancelled ctx = %v, want context.Canceled", err) } } + +func TestDialDoesNotExposePeerReflectedCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + _, _ = conn.Write([]byte("HTTP/1.1 401 " + r.Header.Get("Authorization") + "\r\nContent-Length: 0\r\n\r\n")) + })) + defer srv.Close() + _, err := transport.Dial(context.Background(), transport.DialOptions{ + WSURL: httpToWS(srv.URL), DeviceID: "d", Credential: "private-credential", DaemonVersion: "0.1.0", + }) + if err == nil || !errors.Is(err, transport.ErrPermanent) { + t.Fatalf("want permanent rejection, got %v", err) + } + if strings.Contains(err.Error(), "private-credential") || strings.Contains(err.Error(), "Bearer") { + t.Fatal("upgrade error exposed authorization") + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 2e5ec3eb8..92947cde0 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -29,10 +29,10 @@ registration, qualification and shared acceptance. Verify configuration against actual execution: response defaults must not merely describe values the adapter never applied. -The private native registry persists fenced connection observations and pinned +The Worker persists fenced authenticated daemon connection observations and pinned Environment-event snapshots through the existing execution owner. Session reads and live SSE also expose safe `self_hosted` output and reservation-owned connection -actions. Public self-hosted creation accepts initial text or empty Codex Sessions; +actions. Public self-hosted creation accepts initial text or empty Sessions on the three enabled harness profiles; initial input reserves work while returning the connection target promptly. Later idle text submissions wait for preparation/admission. Cancellation-only events reuse durable admission without creating work or retargeting retries; pending @@ -53,8 +53,10 @@ The three-harness Docker V1 MVP is accepted: Codex, Claude Code and MiniMax Code share the execution/workspace contract, with independent Core/database deployment, Files/Artifacts, cancellation and owned-history continuation. Optional features still differ. See the [accepted scope and evidence](#accepted-milestone-and-evidence). -The same three harnesses also passed separate real E2B V1 qualification in PR #705; -see the [E2B operator guide](../../services/agents-api/deploy/e2b/README.md). +The same three harnesses passed historical Core-managed E2B V1 qualification in +PR #705. That route is retired; it does not qualify the new user-managed daemon +enrollment chain. The [user-managed V1 qualification](user-managed-runtime-v1.md) +records separate real deployment acceptance and its exact scope. Select further work only within current user authorization. Parsar cutover and business Team orchestration are separate from protocol coverage. @@ -81,17 +83,17 @@ paths start at `/vaults`, not `/agents/vaults`. | --- | --- | --- | | Root reusable Agents | create, retrieve, update, list, delete | Partial create/retrieve/update/list/delete and Session references; configuration/error gaps remain | | Skills and Versions | create, retrieve, update default, list, delete, content | [Tenant-owned encrypted bundles and hosted references](environment-templates.md); qualified upload limits and unresolved hosted semantics are recorded explicitly | -| sessions | create, retrieve, update, list, delete | Create (ordinary/live), retrieve, list with root-Agent filter, metadata-only update, public deletion with owned Docker/E2B cleanup; general physical cleanup and exact hosted semantics remain open | +| sessions | create, retrieve, update, list, delete | Create (ordinary/live), retrieve, list with root-Agent filter, metadata-only update, public deletion with owned Docker cleanup; user-managed compute stays caller-owned; general physical cleanup and exact hosted semantics remain open | | sessions.events | create, stream | Text/cancel/function-result admission and live events; function-action state snapshots supported | | sessions.turns | retrieve, list | Implemented reads; lifecycle conformance still partial | | sessions.items | list | Partial Item variants | -| sessions.artifacts | retrieve, list, delete, content | Shared output capture and immutable stored reads/deletion on the accepted three-harness Docker/E2B profiles, including retained downloads after Runtime loss; exact upstream defaults/errors, unchanged-file republishing and cancellation-edge parity remain unverified | +| sessions.artifacts | retrieve, list, delete, content | Shared output capture and immutable stored reads/deletion on accepted Docker profiles and [qualified user-managed workflows](user-managed-runtime-v1.md) (prior Core-managed E2B evidence remains historical), including retained downloads after Runtime loss; exact upstream defaults/errors, unchanged-file republishing and cancellation-edge parity remain unverified | | sessions.subagents | retrieve, list | Missing | | sessions.subagents.items | list | Missing | | sessions.subagents.turns | retrieve, list | Missing | | sessions.subagents.turns.items | list | Missing | -| environments | retrieve | Supported Codex self-hosted and three-harness Docker/E2B hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | -| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker/E2B workspaces; Codex self-hosted listing is a separate supported path. Full listing, overwrite and error semantics remain partial | +| environments | retrieve | Three-harness colocated self-hosted implementation and qualified Docker hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | +| environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. Full listing, overwrite and error semantics remain partial | | environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline/referenced Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps | | vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing | | vaults.credentials | create, retrieve, update, list, delete | Static-bearer create/retrieve/list/token replacement/deletion with scoped encrypted storage; Session attachment and exact-URL HTTPS MCP binding; OAuth, archive semantics and full hosted lifecycle parity remain missing | @@ -135,6 +137,9 @@ new model-issued command after a recovery prompt, not automatic API replay. ### E2B V1 qualification +This is historical evidence for the retired Core-managed E2B route. It does not +qualify current user-managed E2B enrollment or transfer compute ownership to Core. + PR #705 (`9cd1c46c7fab6eeef8cb35ce71f1ea0ca2cf8bc1`) separately qualified Codex, Claude Code and MiniMax Code with actual E2B and real Kimi/MiniMax APIs. Fixed SDK/raw HTTP acceptance covered independent Core/database deployment, @@ -259,7 +264,7 @@ including further deployment qualification; this inventory describes merged beha asynchronous cancellation request while internal finalization remains available. Existing streams close on observing removal without an invented deletion event. Creation keys remain reserved (local 409); missing/repeated deletion locally - returns 404. Qualified managed Docker/E2B deletion also reclaims its owned Runtime; + returns 404. Qualified managed Docker deletion also reclaims its owned Runtime; broader physical SQL/native history cleanup, immediate native quiescence and exact hosted error/retry/overlapping-stream semantics remain unverified or unimplemented. Shared devices, saved Agents and other Sessions are independent. @@ -282,9 +287,9 @@ including further deployment qualification; this inventory describes merged beha the service's `auto` policy; complete upstream-default/error/retry conformance is unverified. HTTP MCP with explicit `service` origin and boolean `required` (default false) supports saved configuration and Codex `none` execution, - plus `self_hosted` execution behind explicit combination capabilities. Remote - static Bearer authentication additionally requires `mcp_http_remote_bearer_auth`; - it keeps the secret in the trusted service native process. Required initialization + with Claude SDK + also supporting its qualified `none` subset. V1 `self_hosted` explicitly rejects + service-origin MCP; the old remote combination is retired. Required initialization additionally needs `mcp_http_required` on the pinned native profile. Native root thread creation/cold resume must initialize required servers before a native Turn starts; failure cannot silently replace retained history. Public acceptance @@ -350,8 +355,9 @@ including further deployment qualification; this inventory describes merged beha - List operations use the upstream `after`, `limit`, `order` and resource-specific filters. Stream events preserve the upstream discriminators and payload shapes. - The upstream self-hosted environment includes an exec-server `remote_url`. - A Parsar daemon socket is not automatically compatible with that transport. - Provider adaptation must be explicit and verified before advertising support. + V1 retains that public resource field while explicitly selecting our private + daemon transport. It does not claim stock `exec-server` wire interoperability; + public resource semantics require independent acceptance. - Environment retrieval returns `object: agent.environment`, its ID/type, durable resource status and required non-null `files`, `plugins` and `skills` arrays. Hosted initial files report safe frozen metadata; empty arrays do not @@ -372,7 +378,7 @@ and cancellation. This does not close the remaining protocol/transport gaps. | Capability | Current state | | --- | --- | -| Independent deployment | Source-free Core package and separate execution PostgreSQL ownership; managed Docker/E2B Runtime co-locates daemon, selected harness and workspace; no Parsar dependency | +| Independent deployment | Source-free Core package and separate execution PostgreSQL ownership; Docker-hosted and user-managed Runtime colocate daemon, selected harness and workspace; Core owns Docker only; no Parsar dependency | | Saved Agents and Sessions | Saved Agent routes, immutable inline/referenced Session configuration, metadata updates, root-Agent filtering and scoped cursor pagination | | Public execution | Initial/later text, active input and cancellation through Codex, Claude Code or MiniMax Code; Codex/Claude additionally support qualified public functions; see profile limits below | | Pending function actions | Persisted calls/results/application receipts, `required_actions`, Session `requires_action`, Turn `waiting`, and live state snapshots; other interactions remain incomplete | @@ -380,32 +386,37 @@ and cancellation. This does not close the remaining protocol/transport gaps. | Execution ownership | Immutable Session engine/device, durable input receipts and database writer fencing; uncertain claimed work fails on restart, without blind replay | | Files and Artifacts | Bounded Environment listing and inline/file_id copies into qualified V1 workspaces; source-file lifecycle and immutable output capture/download/deletion; [Files limits](environment-files.md), [source limits](source-files.md) | | Clients | Fixed Python SDK 3.13.0 and official Go SDK v3.61.0; raw HTTP and real provider acceptance supplement controlled tests | -| Release and product | Registry publication and Parsar cutover remain open; basic Docker/E2B provisioning is operator opt-in; business Team orchestration is deferred | +| Release and product | Registry publication and Parsar cutover remain open; Docker hosting and user-side E2B provisioning are explicit opt-ins; business Team orchestration is deferred | ### Public engine profiles `AGENTS_API_ENGINE` supplies the default for new Sessions. The optional [Core harness extension](harness-selection.md) explicitly selects an enabled engine; -existing Sessions retain their immutable choice. Model identity is independent. +existing Sessions retain their immutable choice. `AGENTS_API_HARNESSES` explicitly +adds installed deployment profiles without requiring a managed Provider. Model +identity is independent. All three profiles require disabled `multi_agent`, implicit reasoning, service tier `auto` and ordinary text output. Optional tools/configuration are qualified per operation and placement; native support is not public admission by itself. | Engine | Qualified placements and limits | | --- | --- | -| `codex` (default) | `none`, the bounded official `self_hosted` path and Docker/E2B `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none`/`self_hosted`, not hosted; supported verbosity follows the native policy below | -| `claude_sdk` | `none` and Docker/E2B `openai_hosted`; medium verbosity, object-root function schemas and text-only function results; anonymous/static-bearer service-origin HTTP MCP with either required value on `none`; hosted service-origin HTTP MCP remains unsupported | -| `mcode` | `none` text and Docker/E2B `openai_hosted` workspace execution; medium verbosity; public functions/service-origin MCP, image input and complete public usage breakdown remain unsupported | - -All three hosted profiles reuse the [Docker](environments.md#basic-public-docker-hosted-profile) -or [E2B](environments.md#basic-public-e2b-hosted-profile) provider lifecycle, -workspace Files/Artifacts, cancellation and recovery queries, with engine-specific -native isolation. Configuration and immutable Runtime images/templates require explicit -operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), -[Claude Code](../../services/agents-api/deploy/claude/README.md), and -[MiniMax Code](../../services/agents-api/deploy/mcode/README.md). The -[E2B guide](../../services/agents-api/deploy/e2b/README.md) packages those qualified -images as pinned templates. +| `codex` (default) | Qualified `none` and Docker `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none` only; verbosity follows native policy | +| `claude_sdk` | Qualified `none` and Docker `openai_hosted`; medium verbosity, object-root function schemas and text-only results; qualified anonymous/static-bearer service-origin HTTP MCP on `none` | +| `mcode` | Qualified `none` text and Docker `openai_hosted`; medium verbosity; public functions/service-origin MCP, image input and complete public usage breakdown remain unsupported | + +All three profiles implement user-managed `self_hosted` enrollment at `/workspace` +through our private daemon transport; [separate real acceptance](user-managed-runtime-v1.md) +records qualified deployments and limits. Service-origin HTTP MCP is rejected on `self_hosted` and hosted local +placements. This does not remove separately qualified Environment Plugin MCP. +The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains +workspace Files/Artifacts, cancellation and recovery with native isolation. +Configure immutable Runtime images explicitly: [Codex](../../services/agents-api/deploy/codex/README.md), +[Claude](../../services/agents-api/deploy/claude/README.md), +[MiniMax](../../services/agents-api/deploy/mcode/README.md). +[E2B packaging](../../services/agents-api/deploy/e2b/README.md) reuses the Runtime +with the official SDK; the user owns provisioning, renewal and destruction. + The shared initialization path supports env/setup and system/npm/Python packages; see the [evidence and limits](environment-templates.md#verification). Remaining unsupported startup installations, unqualified restricted hostname forms and hosted @@ -416,8 +427,8 @@ and Claude anonymous HTTP or HTTPS bearer without literal headers. This batch does not qualify those new Plugin paths on E2B. MiniMax's private workspace MCP bridge remains internal transport, distinct from installed Environment MCP servers. -The [Codex self-hosted profile](environments.md) remains distinct from managed -Docker/E2B and from future user-managed Runtime enrollment. Product `claude_code` +The [self-hosted profile](environments.md#initial-public-self-hosted-profile) uses +user-managed Runtime enrollment and remains distinct from Core-managed Docker. Product `claude_code` is likewise a separate integration from the API's `claude_sdk` engine key. Unsupported configurations fail before Session creation; unsupported results fail before a batch write. Native capability claims cannot replace service profile @@ -522,16 +533,11 @@ its restrictive profile with no built-in tools and only declared function callba A missing capability or unsupported native method fails rather than silently allocating a local execution environment. Native state still lives on the host; function callbacks may access their own resources. This is not filesystem isolation. -Private `daemon` snapshots and the self-hosted registry/Noise transport are -distinct from this mode. - -The native reference is Codex `rust-v0.153.4`, commit -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, especially -`codex-rs/exec-server/src/environment_provider.rs`. The self-hosted registry -requires executor registration, harness authorization and encrypted relay; -a daemon WebSocket URL is not that protocol. -The [Environment assessment](environments.md) records all environment/template/file -operations, ownership, native authentication gaps and the implementation sequence. +User-managed `self_hosted` uses an exact enrolled local Runtime instead. The +former native registry/Noise transport is retired. The pinned native source remains +a dependency reference, not a requirement to expose its executor protocol. +The [Environment assessment](environments.md) separates current boundaries from +historical native transport evidence. ### Public execution admission @@ -776,7 +782,7 @@ Creator fields remain internal and do not extend the public Session schema. Executor keys now require the target Session's verified project and typed creator, with optional exact-Environment restriction. Key issuance can precede Session creation; rotation/revocation and current authorization reuse the durable ledger -and existing native registry. Historical keys remain revoked and unclaimed. This +and exact Runtime enrollment/gateway binding. Historical keys remain revoked and unclaimed. This executor-specific prerequisite does not open public Environment admission or establish complete ownership, hosted key lifecycle or error compatibility. See the [standalone configuration](../../services/agents-api/README.md#standalone-http-service). diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index 4a237734f..08ab0d336 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -3,12 +3,14 @@ The complete protocol target remains the SDK pinned in [upstream.json](upstream.json). The public GET and POST `/agents/environments/{id}/files` have partial coverage. Inline and source-file (`file_id`) creation target a qualified V1 local Environment, -including the managed Docker/E2B profiles for Codex, Claude Code and MiniMax Code. +including the Core-managed Docker profiles for Codex, Claude Code and MiniMax Code. [Source Files](source-files.md) have their own project-owned lifecycle. Managed hosted provisioning and shared Artifacts are accepted within the [recorded Docker MVP scope](README.md#accepted-milestone-and-evidence) and separate -[E2B qualification](README.md#e2b-v1-qualification); complete -Files/Environment semantics and other providers are not implied. +historical Core-managed [E2B qualification](README.md#e2b-v1-qualification). The +new user-managed enrollment chain reuses local Files with separate +[real public acceptance](user-managed-runtime-v1.md); complete Files/Environment +semantics and other providers are not implied. ## Pinned contract @@ -83,7 +85,8 @@ It never starts a model for upload or supplies a filesystem root from the reques The deployment must qualify the protected sibling workspace/staging layout and its selected native adapter. The [engine profile guides](README.md#public-engine-profiles) describe accepted Docker configurations; the [E2B operator guide](../../services/agents-api/deploy/e2b/README.md) -adds the qualified E2B deployment. A capability or path declaration alone +covers user-managed E2B Runtime packaging and links its separate real acceptance. +A capability or path declaration alone does not establish isolation or public hosted admission. Before sending any bytes, persist the mutation identity and request digest under diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 8dbff9040..8db02b082 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -83,8 +83,11 @@ idle Session and wait for connected status before submitting input. Uncertain writes and Core restart during initialization fail the new Environment and reclaim it; they do not replay partial installation. After completion, reconnect and native-history recovery preserve user modifications instead of reinstalling files. -Docker/E2B and all three harnesses use this same lifecycle. The Provider API remains -five operations; public Templates are never E2B image templates. +Current Core-hosted Docker and all three harnesses use this lifecycle. The Provider +API remains five operations; public Templates are never E2B image templates and +remain hosted-only. E2B now uses user-managed Runtime enrollment through the official +SDK. Historical Core-managed E2B evidence below retains its original scope and does +not qualify that new chain. ## Skills and versioned references @@ -558,6 +561,11 @@ E2B self-hosted onboarding or complete upstream network semantics. ### Resource and initialization checks +The E2B fixture and opt-in flags described below are historical evidence for the +retired Core-managed deployment, retained in Git history at `d03e1d25`. Current +user-managed E2B uses the shared daemon enrollment path and does not resolve hosted +Templates. These historical tests do not qualify the replacement deployment. + `official_environment_templates.py` checks all five fixed-SDK operations plus raw HTTP, exact safe response shapes, field replacement/defaults, pagination, tenant isolation and rejected confidential canaries. `official_e2b_v1.py` opts in with diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index acac9e589..0dab75d58 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -1,15 +1,13 @@ # Environment contract and implementation path -This assessment covers the fixed [Python SDK contract](upstream.json). It is an -implementation plan with partial current coverage. Public execution admits -`environment.type=none` on Codex and Claude SDK, the Codex self-hosted text/function -profile, and operator-configured Docker/E2B hosted profiles for Codex, Claude Code -and MiniMax Code below. -Environment retrieval supports safe metadata for these environment profiles; -[reusable templates and initial files](environment-templates.md) share inline initialization. -Other populated startup installations remain missing. Live file listing -and local inline/source writes have [partial coverage and explicit local policies](environment-files.md). -See [current coverage](README.md#public-semantics). +This assessment covers the fixed [Python SDK contract](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](user-managed-runtime-v1.md). +Core does not allocate E2B. Users own E2B allocation, renewal and cleanup +through the official SDK and [Runtime packaging](../../services/agents-api/deploy/e2b/README.md). +[Templates](environment-templates.md) remain a hosted-only resource path; +[Files](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. @@ -20,30 +18,14 @@ and the preparation/admission path below; additional provider profiles remain op Missing/`none` configurations and historical internal snapshots gain no backfill. -The native Codex registry uses principal executor digest bindings with optional exact-Environment -restrictions, the existing execution owner and scoped Store reads. Registration and current -socket identity are process-local; the returned WebSocket capability expires for -new connections after five minutes. Restart invalidates registrations, causing the -native executor to register again. Replaced socket callbacks cannot clear a newer -connection. Current socket observations now commit `connected`/`disconnected` and -immutable pinned Environment-event snapshots through the leased Store. Replacement -and revision fencing prevent late observations from overwriting successors; startup -reconciliation removes the previous process's connection evidence. Registration -alone is not connection, and connection is not native readiness. The public text -profile and resource reads use this bridge. The canonical -[observation and shutdown rules](../../CONTRIBUTING.md#environment-ownership-and-placement) -cover write failures and recovery. Deleting the owning Session rejects new requests and closes -existing sockets on the next ownership heartbeat. A previous holder of a still-valid -executor credential can register again; permanent exclusion requires revocation. -Execution owners now obtain transient harness credentials through the internal -registry after exact tenant/Environment and execution-lease authorization. Their -owner context spans preparation and the transferred Run; release/cancellation -invalidates the credential and its own grants/pair. Static harness keys are retired. -These credentials obtain short-lived, key-bound connection grants. -The relay pairs one harness with the current executor socket and forwards native -binary frames unchanged. Either peer loss closes both physical connections and -invalidates grants; no queued frames or commands move to a successor. Refresh does -not disturb a healthy pair. See the [operator prerequisite](../../services/agents-api/README.md#native-executor-transport-prerequisite). +The private daemon gateway authenticates the enrolled Environment executor key and +exact dedicated device. Existing Worker connection observations retain generation +and revision fencing; registration and connectivity do not establish native +readiness. Rotation/revocation, Session deletion and ownership loss deny further +access without promising immediate cessation of native effects. Native history +remains local to the bound Runtime and cannot be replaced on retry. There is no +registry/Noise relay or transient service-side harness credential. +See the [enrollment guide](../../services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile @@ -62,10 +44,11 @@ Omitted/null network defaults to enabled. Enabled, disabled and exact-host restr policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through the hosted initializer. -Unsupported hostname forms, Plugins and capability-directory imports reject explicitly; see +Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation -defaults produce safe empty metadata, not a live workspace inventory. Hosted MCP -combinations remain unimplemented. +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. Initial provisioning leaves a Session idle until a Turn starts, with no caller connection action. The managed scan records authenticated, exactly bound daemon @@ -77,26 +60,26 @@ outcomes; new inputs reject terminal Environments. Expiry has no invented SSE variant. Local failure codes and exact event ordering remain unverified upstream semantics; this profile does not establish complete Environment compatibility. -## Basic public E2B-hosted profile +## User-managed E2B profile -The [E2B operator configuration](../../services/agents-api/deploy/e2b/README.md) -selects a qualified immutable template/build for the same three harnesses and -`type=openai_hosted` admission. It retains the shared Runtime execution, Files, -Artifacts and recovery paths and the public configuration limits above. Actual -[three-harness E2B acceptance](README.md#e2b-v1-qualification) is separate from -Docker evidence. The Provider's five operations manage allocation, initialization, -lease renewal and cleanup only. A minimum two-hour renewable lease is required; -expiry destroys volatile VM workspace/history and cannot authorize replay or -transparent recreation. Pausing, migration and user-managed enrollment are not -part of this qualified profile. +The application creates, renews and destroys its E2B sandbox through the official +SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` +Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. +The [E2B guide](../../services/agents-api/deploy/e2b/README.md) owns packaging and +user-side lifecycle instructions. Expiry or lost workspace/history must not trigger +transparent replacement or replay. The [new enrollment qualification](user-managed-runtime-v1.md) +records its own real deployment evidence. +The [prior E2B qualification](README.md#e2b-v1-qualification) concerns the retired +Core-managed topology only. ## Initial public self-hosted profile -Create a Session with `environment.type=self_hosted`, an absolute -`workspace_directory` and omitted/null/empty `capability_directories`. Creation +Create a Session with `environment.type=self_hosted`, +`workspace_directory: "/workspace"` and omitted/null/empty `capability_directories`. Creation accepts initial text as a string or ordered user-message array. Omitted/null input -creates no Turn or connection action. Configured execution, a validated registry origin, -Codex and supported non-deferred function definitions are validated before persistence. +creates no Turn or connection action. Configured execution, an enabled harness and +the exact local profile are validated +before persistence. Supported optional functions remain engine-specific. Initial text commits a reservation and connection action, then returns the Session and Environment connection target while offline. Streamed creation sends its @@ -130,14 +113,18 @@ 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, nonempty capability directories and other -engine placements are rejected temporary gaps. The current adapter also rejects -workspace paths containing NUL, CR, LF or backslash; broader path/platform support -remains open. Native execution still uses the -scoped upstream-library launcher; arbitrary-domain stock CLI support is not proven. -The built-service acceptance must publicly create and submit, keep a request open -past 30 seconds, and verify two real remote command/file/history Turns through -fixed SDK, raw HTTP and live SSE. Private setup alone is insufficient. +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, +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. + +Mechanism tests cover enrollment, credential checks and exact-device dispatch. They +do not establish real public qualification. Before claiming that qualification, +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. ## Contract inventory @@ -202,13 +189,12 @@ transport to adapters. Logical ownership does not require a machine per object. | Provider allocation | Compute and filesystem lifetime; caller-owned for `self_hosted`, service-owned for hosted provisioning. | | Device and daemon connection | Authenticated engine-host identity and replaceable internal dispatch transport. | | Harness process and native Session | Native model/tool loop, execution state and proven history/continuation path. | -| Executor connection | Access to an Environment's filesystem/process capabilities, independently authorized. | +| Runtime enrollment | Exact Environment/device/key binding for user-managed compute; no service-owned allocation. | -A co-located daemon/harness/workspace is a proposed placement for engines with -native local tools. A harness using a separate executor is another placement. -Neither proposal establishes public compatibility by itself. Advertising the -specified `self_hosted` flow requires an actual caller-started executor to work; -quietly requiring an extra Parsar daemon installation changes that flow. +Daemon, harness, tools and workspace are colocated in V1. Our daemon fills the +executor role; a separate native executor and service-side harness are retired. +The pinned public resources remain the target, while stock executor wire +interoperability is explicitly outside this implementation. Co-location needs a real credential and isolation design: generated code must not gain the broader application credential or cross-tenant secrets through a shared @@ -217,7 +203,16 @@ 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. -## Evidence and interoperability gap +## Historical remote-executor assessment + +The remainder of this document records the former Codex registry/Noise topology +and its original bounded evidence. It is retained for provenance, not current +installation instructions or acceptance of the V1 enrollment chain. Its remote +probe suites, separate harness package and launcher have been retired. References +to their source paths describe the historical revision; use Git history to inspect +them. Current deployment and validation requirements are above. + +### Evidence and interoperability gap The current [self-hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/self-hosted) uses a restricted executor key and both returned values: @@ -302,9 +297,9 @@ arbitrary interrupted-work replay, native crash restoration, TLS deployment and the stock CLI's production-domain restriction remain open. Other harnesses retain their own native placement and execution protocols. -## Native app-server placement prerequisite +### Native app-server placement prerequisite -The opt-in [real-provider fixture](../../services/agents-api/tests/native/README.md) +The opt-in real-provider fixture (historical source: `../../services/agents-api/tests/native/README.md`) adds stock app-server execution to the accepted PostgreSQL registry/relay. It keeps local harness history separate from a container-only executor workspace, exercises real MiniMax shell/file use, and resumes the same native thread after a fresh @@ -341,14 +336,14 @@ Scoped credentials, placement trust and long-Turn reconnect lifetime remain expl dispatch prerequisites. Public acceptance must verify those boundaries, readiness and real API/daemon execution together. -## Private daemon adapter +### Private daemon adapter The registered-daemon fixture extends placement through the authenticated gateway, capability heartbeat and typed remote descriptor. The adapter consumes transient connection credentials, verifies native readiness and selects the executor on first and cold-resumed Turns. Local harness history remains separate from remote files. See [the contributor boundary](../../CONTRIBUTING.md) and -[the real-provider fixture](../../services/agents-api/tests/native/README.md) for +the real-provider fixture (historical source: `../../services/agents-api/tests/native/README.md`) for supported native version, rejected combinations and acceptance commands. The Codex adapter now separates preparation from prompt start using the same native @@ -368,9 +363,9 @@ native detached cleanup may delay that exit. Complete resource lifecycle and complete public cancellation settlement remain separate from the initial text profile. -## Shared native filesystem prerequisite +### Shared native filesystem prerequisite -The opt-in [shared-owner fixture](../../services/agents-api/tests/native/README.md#shared-native-filesystem-owner) +The opt-in shared-owner fixture (historical source: `../../services/agents-api/tests/native/README.md#shared-native-filesystem-owner`) characterizes direct remote file operations alongside the upstream native model/tool loop. It uses one injected `EnvironmentManager` and one authorized registry pair; it does not open another harness connection or use stock host-only `fs/*` calls. @@ -385,7 +380,7 @@ lifetime and daemon integration remain prerequisites. Public file create/list, uploaded file references, workspace path semantics, pagination and installation inventory remain unimplemented by this experiment. -## Pending input storage prerequisite +### Pending input storage prerequisite A private Store reservation can retain one ordered message batch without a Turn, Items or Turn events. It shares request identity with direct input admission and @@ -446,7 +441,7 @@ outcome; without an observed final Done, delivery records an unknown failure. Preparation failure cannot discard a cancellation receipt already being awaited. Complete cancellation output/Usage and native cleanup remain required work. -### Pending input activity and Session reads +#### Pending input activity and Session reads The latest relevant reservation now owns a narrow pre-Turn activity projection. Pending offline input emits `requires_action` with `environment_connection`; @@ -477,7 +472,7 @@ execution/registry configuration or invoke native work. Populated metadata, file operations beyond the current Files profile, populated hosted output and complete Environment conformance remain separate work. -## Dependency-ordered implementation +### Dependency-ordered implementation 1. **Executor interoperability.** Demonstrate the documented unmodified executor command with supported authentication, then native harness authorization, @@ -514,7 +509,7 @@ validation gaps. Preserve those gaps in the board and reassess its complete priorities after each accepted slice. No placeholder resource, permissive SDK parse or synthetic execution test establishes this roadmap as implemented. -### Durable executor credential prerequisite +#### Durable executor credential prerequisite The native registry authenticates connect-only executor keys against the target Session's verified project partition and immutable typed creator. Keys may be diff --git a/contracts/agents-api/harness-selection.md b/contracts/agents-api/harness-selection.md index 62d5879a3..843c945a9 100644 --- a/contracts/agents-api/harness-selection.md +++ b/contracts/agents-api/harness-selection.md @@ -34,8 +34,13 @@ resources and native histories. Agent edits do not change accepted Sessions. ## Operator configuration -Existing `AGENTS_API_ENGINE` and `default_provider` deployments keep their default -behavior. A deployment enabling multiple hosted harnesses adds `engine_providers` +`AGENTS_API_ENGINE` selects the default engine. `AGENTS_API_HARNESSES` explicitly +adds comma-separated deployment-supported engines, for example +`codex,claude_sdk,mcode`, without requiring a managed Provider. The default engine +and configured managed engine profiles remain enabled; unknown names fail startup. +This setting does not install a harness or qualify a native deployment. + +Existing `default_provider` deployments keep their default behavior. A deployment enabling multiple hosted harnesses adds `engine_providers` to `AGENTS_API_MANAGED_RUNTIMES_FILE`: ```json diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 6f7b53358..d3dc634f0 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -41,10 +41,11 @@ checks. Additional capability combinations require evidence, not an engine-name exception. The static registry requires a build to add an implementation; dynamic plugin loading and untrusted code execution are outside this design. -New Session selection currently uses the operator's `AGENTS_API_ENGINE` setting; -existing Sessions retain their engine. The default is a deployment convenience, -not a different contract or authority level. There is no invented public `harness` -field. Future selection changes must respect the pinned public protocol. +New Session selection uses the default `AGENTS_API_ENGINE` or the documented +[harness extension](harness-selection.md). `AGENTS_API_HARNESSES` explicitly adds +deployment-supported profiles without requiring a managed Provider; existing +Sessions retain their engine. The default is a deployment convenience, +not a different contract or authority level. The documented extension remains separate from the pinned public protocol. ## Shared behavioral obligations @@ -78,14 +79,17 @@ syntactically or everything either upstream harness can theoretically perform. | Function image results | Supported subset | Gap; currently rejected | | Non-default verbosity | Native/model-dependent support | No equivalent qualified; medium only | | Public detailed Usage | Supported native counters | Native raw usage retained; public breakdown gap | -| Official `self_hosted` remote executor path | Existing native Codex path | Not qualified; requires separate design | +| V1 `self_hosted` daemon enrollment at `/workspace` | [Qualified deployment scope](user-managed-runtime-v1.md) | [Qualified deployment scope](user-managed-runtime-v1.md) | | Explicit reasoning, structured output, enabled `multi_agent`, message images | Shared service gaps | Shared service gaps | This inventory records supported combinations, not a feature-equality checklist. Do not silently drop options, fabricate measurements, weaken isolation or remove working features. Unsupported operations stay explicit; implementing them is a -separate board decision, not an onboarding prerequisite. User-managed colocated Runtime enrollment is a -separate queued feature; it is not a substitute for official `self_hosted`. +separate board decision, not an onboarding prerequisite. MiniMax also implements the same colocated Runtime enrollment. This V1 decision +uses our daemon as executor and explicitly does not claim stock `exec-server` +interoperability. The old service-side harness/remote executor route is retired. +Service-origin HTTP MCP remains unsupported on `self_hosted`; `none` MCP and +qualified hosted Template Plugin MCP retain their separate scopes. ## Common contract acceptance diff --git a/contracts/agents-api/user-managed-runtime-v1.md b/contracts/agents-api/user-managed-runtime-v1.md new file mode 100644 index 000000000..9d25934ae --- /dev/null +++ b/contracts/agents-api/user-managed-runtime-v1.md @@ -0,0 +1,102 @@ +# User-managed V1 Runtime qualification + +The V1 executor is Parsar's daemon, colocated with the selected native harness, +local tools and workspace. Core runs separately with its own PostgreSQL database. +A caller creates a public `self_hosted` Session and starts Runtime with its exact +Environment ID, returned `remote_url` and scoped executor credential. This private +transport does not interoperate with stock Codex `exec-server` or Noise. + +Core manages Docker for `openai_hosted`. User-managed Docker and E2B use the same +Runtime contract; the application owns their compute. E2B create, information, +renewal and deletion use the official E2B SDK, outside Core. Public execution and +file operations continue through Core and daemon, not E2B commands or files. + +## Accepted scope + +Recorded on 2026-09-22 against source candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. +Documentation-only follow-up commits do not change the accepted binaries. + +| Deployment | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| User-managed Linux amd64 Docker | Accepted | Accepted | Accepted | +| User-managed E2B, immutable Runtime template | Accepted | Accepted | Accepted | +| Real model | Kimi K3, Responses | Kimi K3, Anthropic-compatible API | MiniMax M2.7, Anthropic-compatible API | + +Each complete deployment run uses official OpenAI Python SDK 3.13.0 at the pinned +upstream commit plus raw HTTP and live SSE. The common four-Turn acceptance checks: + +- Public inline Files.create bytes, native command execution and two actual output + files; Files.list path/order/pagination and immutable Artifact downloads through + SDK/raw HTTP, including foreign-tenant denial. +- Daemon and Core restart with unchanged committed Items, preserved conversation + history and outputs, and no repeated publication effects. +- Cancellation of a foreground native process, stopped file effects, idempotent + repeat cancellation and continued execution without restarting cancelled work. +- Native tools cannot read executor credentials/private staging witnesses or + inherit known private credential values. Public responses contain no known + private credentials. Private witnesses remain unchanged across restart. + +E2B additionally executes the native isolation probe through a fifth real model +Turn. It checks a witness in the actual native-history directory, the executor +key, staging and protected startup receipt, PID-namespace separation, inaccessible +outer process secrets, and denied envd/sudo/privileged-account access. Official +E2B SDK 2.51.0 receipts record creation, metadata-bound inspection, lease renewal +and explicit kill of each owned sandbox. No replacement Runtime is called recovery. + +The separate real Docker credential lifecycle check covers caller/executor role +separation, principal/tenant/Environment binding, rejection of history rebinding, +key rotation fencing the old socket while retaining device identity, revocation, +and Session deletion without reclaiming caller-owned compute. These shared Core +checks are not claimed as a separate live rotation/revocation run on every E2B +profile. User-managed Sessions create no managed Runtime allocation. + +## Evidence and verification boundaries + +Evidence is retained on `zju_a100_2` under +`~/.parsar/remediation/20260921/self-hosted-onboarding/`: + +- `source-candidate.json` and `source-verified.json`: exact source and binary hashes. +- `live/{codex,claude,mcode}/accepted.json`: complete Docker four-Turn runs before + removal of unused private wire fields. `live/codex/security.json` records the shared + real credential lifecycle checks. +- `live/final-{codex,claude,mcode}/final-smoke.json`: final private wire 0.3.0 + binaries, real native command and Files/Artifacts/tenant readback. This is a + focused regression, not another complete Docker lifecycle run. +- `live-e2b/{codex-attempt3,claude-attempt1,mcode-attempt1}/`: final five-Turn + E2B acceptance and cleanup (208.562s, 147.053s and 125.647s respectively). +- `e2b-builds/final-daemon/`: immutable template receipts and verified daemon hash. +- `make-check-final.log`: full gate at `ce11501`, including dedicated real + PostgreSQL, fixed official client, all service/daemon packages, sqlc regeneration, + independent builds, Claude/MiniMax packaging and retained Rust helper checks. + The subsequent connection-cache cleanup fix at `8f0cd25` passed the full + execution-package regression suite and independent Core build. +- `review-final.json`: independent Astra high review of the complete diff, + performed with reused context under explicit user authorization. It is not a + fresh-context blind review. + +The final Docker smoke runner initially called a test helper with the wrong +signature after native execution. A read-only follow-up completed the assertions +against those exact Turns without model replay. Original failed logs are retained. +The first E2B Codex launch had an uncertain network result and was reconciled by +metadata without retrying that Create. The second run stopped on an operator +readiness-wrapper error after restart. Its owned VM was removed before the fresh +third run; the failed records remain failures, not accepted execution evidence. + +## Limits + +Qualification is bounded to these immutable Linux amd64 Runtime builds and their +recorded real models. It does not establish arbitrary-host isolation, high +availability, full Agents API conformance, Anthropic-model acceptance for Claude, +or identical optional capabilities across harnesses. + +The public self-hosted profile accepts `/workspace` and empty capability +directories. Service-origin HTTP MCP on self-hosted remains explicitly rejected; +none-environment HTTP MCP and separately qualified hosted Plugin MCP keep their own +scope. Environment Templates remain hosted-only. Unspecified upstream defaults, +errors, lifecycle edge cases and broader resource semantics remain in the protocol +coverage ledger. + +Runtime workspace and native history must survive restart. Expiry or loss of that +state cannot authorize silent replacement or replay. A successful disconnect, +revocation or Session deletion is not a guarantee that every native effect has +stopped; the compute owner remains responsible for termination and cleanup. diff --git a/contracts/agents-api/workspace-placement.md b/contracts/agents-api/workspace-placement.md index a5c87991f..d13a9f335 100644 --- a/contracts/agents-api/workspace-placement.md +++ b/contracts/agents-api/workspace-placement.md @@ -1,10 +1,31 @@ -# Two-engine workspace placement +# Workspace placement -This decision serves the Codex/Claude single-Agent milestone. It does not enable -a public profile or change the pinned [Environment contract](environments.md). -Ownership rules remain in [CONTRIBUTING.md](../../CONTRIBUTING.md#environment-ownership-and-placement). +V1 colocates daemon, selected harness, native tools and `/workspace` in one Runtime. +Our daemon is the user-side executor for `self_hosted`. Enrollment freezes the exact +Session/Environment/device/key binding; it creates no managed allocation and cannot +move a Session to another device. Core manages Docker hosting only. Users manage +local or E2B Runtime creation, renewal and destruction through the official SDK. -## Current implementation and missing prerequisites +All three harnesses reuse typed `LocalEnvironment`, existing preparation/start/ +cancel ownership and protected local Files/Artifacts. Native credentials and +histories remain inaccessible to generated tools. Strict resume requires retained +history; connectivity alone establishes neither readiness nor isolation. Current +implementation is recorded in the [Environment profile](environments.md#initial-public-self-hosted-profile); +[real qualification](user-managed-runtime-v1.md) identifies accepted deployments and limits. + +The pinned public Environment resources and `remote_url` remain unchanged, while +that URL names our private daemon transport. Stock `exec-server` interoperability, +registry/Noise relay and service-side harness/remote tool forwarding are retired. +Service-origin HTTP MCP is rejected on `self_hosted`; qualified `none` MCP and +hosted Template Plugin MCP retain their separate boundaries. + +## Historical two-engine assessment + +The assessment below records the earlier topology and native prerequisites at its +original scope. It is not current installation guidance or proof of the V1 daemon +enrollment chain. Referenced retired package/probe sources remain in Git history. + +### Current implementation and missing prerequisites | Boundary | Codex | Claude Agent SDK | | --- | --- | --- | @@ -23,7 +44,7 @@ loop. A public `self_hosted` implementation must still support the documented caller-started executor flow; a private daemon URL or an extra installation step cannot silently replace it. -## Claude isolation prerequisite +### Claude isolation prerequisite There are two separate boundaries. The deployment excludes broader application, daemon and other-tenant credentials from the harness environment and mounted @@ -60,7 +81,7 @@ Upstream [sandbox documentation](https://code.claude.com/docs/en/sandboxing) and [deployment guidance](https://code.claude.com/docs/en/agent-sdk/secure-deployment) provide context; current documentation does not replace the pinned source. -## Execution and file ownership +### Execution and file ownership `execution.RunEnvironmentInput` currently owns a connection through preparation and one Run, then releases it. That is not an idle file owner. Existing durable @@ -91,10 +112,10 @@ socket client's consumer queue is unbounded. The optional private harness artifa therefore uses stock raw stdio with the existing Go RPC and a separate local metadata socket into the same manager. It does not create a second executor pair or call host-local `fs/*` for a remote path. Patch ownership, exact builds and -acceptance are defined in the [artifact guide](../../packages/codex-harness/README.md). +acceptance are defined in the artifact guide (historical source: `../../packages/codex-harness/README.md`). This does not enable public Files, a reusable idle owner or full transport bounds. -## Acceptance and next slice +### Acceptance and next slice Start with synthetic credential/file/socket canaries using the pinned native tools. Stop on disclosure, bypass or fallback; never widen access to obtain a diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go index e576e4bef..9d411adb3 100644 --- a/internal/agentdaemon/device/state.go +++ b/internal/agentdaemon/device/state.go @@ -70,7 +70,6 @@ type KindCapabilities struct { ToolItems bool `json:"tool_items,omitempty"` ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` - RemoteEnvironment bool `json:"remote_environment,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` Preparation bool `json:"preparation,omitempty"` @@ -78,18 +77,16 @@ type KindCapabilities struct { WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` WebSearchControl bool `json:"web_search_control,omitempty"` // ExecutionControls supports typed search and verbosity controls. - ExecutionControls bool `json:"execution_controls,omitempty"` - TextVerbosity bool `json:"text_verbosity,omitempty"` - SubagentControl bool `json:"subagent_control,omitempty"` - FunctionTools bool `json:"function_tools,omitempty"` - MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` - MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` - MCPHTTPRemoteEnvironment bool `json:"mcp_http_remote_environment,omitempty"` - MCPHTTPRemoteBearerAuth bool `json:"mcp_http_remote_bearer_auth,omitempty"` - MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` - DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` - DurableTurns bool `json:"durable_turns,omitempty"` - WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` + ExecutionControls bool `json:"execution_controls,omitempty"` + TextVerbosity bool `json:"text_verbosity,omitempty"` + SubagentControl bool `json:"subagent_control,omitempty"` + FunctionTools bool `json:"function_tools,omitempty"` + MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` + MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` + MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` + DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` + DurableTurns bool `json:"durable_turns,omitempty"` + WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` } // SupportedAgentKind is the sanitized runtime.config view @@ -104,7 +101,9 @@ type SupportedAgentKind struct { // Heartbeat is the WebSocket daemon heartbeat // payload after gateway normalization. type Heartbeat struct { - RuntimeID string + RuntimeID string + // CredentialHash comes from gateway authentication, never a daemon frame. + CredentialHash string DaemonVersion string ActiveRequests int HeartbeatTimestamp int64 diff --git a/internal/agentdaemon/gateway/handler.go b/internal/agentdaemon/gateway/handler.go index 0d64273ae..a55214df7 100644 --- a/internal/agentdaemon/gateway/handler.go +++ b/internal/agentdaemon/gateway/handler.go @@ -90,7 +90,7 @@ func NewHandler(cfg HandlerConfig) *Handler { ReadBufferSize: 4096, WriteBufferSize: 4096, // Daemon is a non-browser client and sends no Origin; - // the bearer in the query param is the actual auth boundary. + // the Authorization bearer is the actual auth boundary. CheckOrigin: func(*http.Request) bool { return true }, }, } @@ -101,11 +101,11 @@ func NewHandler(cfg HandlerConfig) *Handler { // which fans synthetic error/done to every active subscriber. // // @Summary Agent-daemon WebSocket upgrade -// @Description Long-lived duplex channel for daemon runtimes. Authenticated by the runner bearer passed as a query param since websockets have no header stage before upgrade. +// @Description Long-lived duplex channel for daemon runtimes. Authenticated by the runner bearer in the HTTP Authorization header before upgrade. // @Tags agent-daemon // @ID agentDaemonWebsocket // @Param device_id query string true "device id" -// @Param token query string true "runner bearer credential" +// @Param Authorization header string true "Bearer " // @Param version query string true "daemon protocol version" // @Success 101 {string} string "protocol switched" // @Failure 400 {object} map[string]interface{} @@ -116,10 +116,10 @@ func NewHandler(cfg HandlerConfig) *Handler { func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() deviceID := q.Get("device_id") - token := q.Get("token") + token := bearerFromAuthHeader(r) version := q.Get("version") if deviceID == "" || token == "" || version == "" { - writeAuthError(w, http.StatusBadRequest, "missing_params", "device_id, token, version are required") + writeAuthError(w, http.StatusBadRequest, "missing_params", "device_id, version and Authorization bearer are required") return } if !proto.VersionCompatible(version) { @@ -178,6 +178,7 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { } sess := NewSessionWithOwner(conn, auth.DeviceID, auth.WorkspaceID, version, h.cfg.Registry, h.cfg.Log, lease) sess.heartbeat = h.cfg.Heartbeat + sess.credentialHash = device.HashCredential(token) h.cfg.Log("agentdaemon gateway: ws upgrade ok, registering device_id=%s owner_pod=%s waiters=%d", auth.DeviceID, h.cfg.OwnerPodID, len(h.cfg.Registry.PendingWaiters(auth.DeviceID))) if prev := h.cfg.Registry.Register(sess); prev != nil { diff --git a/internal/agentdaemon/gateway/handler_test.go b/internal/agentdaemon/gateway/handler_test.go new file mode 100644 index 000000000..cec48857b --- /dev/null +++ b/internal/agentdaemon/gateway/handler_test.go @@ -0,0 +1,64 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/gorilla/websocket" +) + +func TestWebSocketRequiresAuthorizationBearer(t *testing.T) { + const credential = "synthetic-runtime-credential" + for _, tc := range []struct { + name, authorization, queryToken string + status int + }{ + {"header", "Bearer " + credential, "", http.StatusSwitchingProtocols}, + {"retired wire version", "Bearer " + credential, "", http.StatusUpgradeRequired}, + {"missing header", "", "", http.StatusBadRequest}, + {"query alone", "", credential, http.StatusBadRequest}, + {"wrong scheme", "Basic " + credential, credential, http.StatusBadRequest}, + {"invalid header cannot use query", "Bearer invalid", credential, http.StatusUnauthorized}, + {"header ignores query", "Bearer " + credential, "invalid", http.StatusSwitchingProtocols}, + } { + t.Run(tc.name, func(t *testing.T) { + registry := NewRegistry() + handler := NewHandler(HandlerConfig{ + Registry: registry, + Authenticator: NewAuthenticator(&stubRuntimeStore{ok: true, row: device.Credential{ + ID: "device", WorkspaceID: "tenant", Type: RuntimeTypeAgentDaemon, + CredentialHash: device.HashCredential(credential), + }}), + }) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + defer server.Close() + query := url.Values{"device_id": {"device"}, "version": {proto.Version}} + if tc.name == "retired wire version" { + query.Set("version", "0.2.0") + } + if tc.queryToken != "" { + query.Set("token", tc.queryToken) + } + endpoint := "ws" + strings.TrimPrefix(server.URL, "http") + "?" + query.Encode() + conn, response, err := websocket.DefaultDialer.Dial(endpoint, http.Header{"Authorization": {tc.authorization}}) + if response == nil { + t.Fatalf("missing upgrade response: %v", err) + } + defer response.Body.Close() + if conn != nil { + defer conn.Close() + } + if response.StatusCode != tc.status { + t.Fatalf("upgrade status = %d, want %d", response.StatusCode, tc.status) + } + if tc.status == http.StatusSwitchingProtocols && err != nil { + t.Fatalf("header-authenticated upgrade failed: %v", err) + } + }) + } +} diff --git a/internal/agentdaemon/gateway/mcp_test.go b/internal/agentdaemon/gateway/mcp_test.go index b4c65fb71..e5a4520c4 100644 --- a/internal/agentdaemon/gateway/mcp_test.go +++ b/internal/agentdaemon/gateway/mcp_test.go @@ -29,49 +29,9 @@ func TestMCPHTTPBearerCapabilitySurvivesHeartbeatMapping(t *testing.T) { } } -func TestMCPRemoteCapabilityIsExplicit(t *testing.T) { - for _, supported := range []bool{false, true} { - heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: supported}}}} - raw, err := json.Marshal(heartbeat) - if err != nil || strings.Contains(string(raw), `"mcp_http_remote_environment":true`) != supported { - t.Fatal("wire capability differs", err) - } - var decoded proto.HeartbeatPayload - if err = json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - s := &Session{} - s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) - info, found, known := s.AgentKindStatus("codex") - if !found || !known || info.Capabilities.MCPHTTPRemoteEnvironment != supported { - t.Fatal("combination capability lost or inferred") - } - } -} - -func TestMCPRemoteBearerCapabilityIsExplicit(t *testing.T) { - for _, supported := range []bool{false, true} { - heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, MCPHTTPBearerAuth: true, MCPHTTPRemoteBearerAuth: supported}}}} - raw, err := json.Marshal(heartbeat) - if err != nil || strings.Contains(string(raw), `"mcp_http_remote_bearer_auth":true`) != supported { - t.Fatal("wire capability differs", err) - } - var decoded proto.HeartbeatPayload - if err = json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - s := &Session{} - s.setSupportedAgentKinds(deviceKindsFromHeartbeat(decoded)) - info, found, known := s.AgentKindStatus("codex") - if !found || !known || info.Capabilities.MCPHTTPRemoteBearerAuth != supported { - t.Fatal("combination capability lost or inferred") - } - } -} - func TestMCPRequiredCapabilityIsExplicit(t *testing.T) { for _, supported := range []bool{false, true} { - heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, MCPHTTPTools: true, MCPHTTPRemoteEnvironment: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: supported}}}} + heartbeat := proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: supported}}}} raw, err := json.Marshal(heartbeat) if err != nil || strings.Contains(string(raw), `"mcp_http_required":true`) != supported { t.Fatal("wire capability differs", err) diff --git a/internal/agentdaemon/gateway/preparation_test.go b/internal/agentdaemon/gateway/preparation_test.go index a525c60c6..ed6ea3acf 100644 --- a/internal/agentdaemon/gateway/preparation_test.go +++ b/internal/agentdaemon/gateway/preparation_test.go @@ -77,7 +77,7 @@ func TestPreparationCloseAndOverflowDoNotInventRunEvents(t *testing.T) { } func TestPreparationCapabilitySurvivesHeartbeatMapping(t *testing.T) { - kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Preparation: true, RemoteEnvironment: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, NativeSessionRecovery: true}}}}) + kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Preparation: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, NativeSessionRecovery: true}}}}) if len(kinds) != 1 || (!kinds[0].Capabilities.Preparation || !kinds[0].Capabilities.LocalEnvironment || !kinds[0].Capabilities.LocalEnvironmentNetworkPolicy || !kinds[0].Capabilities.WorkspaceReadPreparation || !kinds[0].Capabilities.NativeSessionRecovery) { t.Fatal("preparation capability lost") } diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index 4f5b621f6..2d9a7aa7b 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -86,7 +87,8 @@ type Session struct { owner *ownerLease // heartbeat persists daemon-advertised capability snapshots. - heartbeat HeartbeatTouch + heartbeat HeartbeatTouch + credentialHash string hbMu sync.Mutex lastSeenAt time.Time @@ -496,6 +498,7 @@ func (s *Session) handleHeartbeat(env proto.Envelope) { defer cancel() status, err := s.heartbeat.TouchAgentDaemonHeartbeat(ctx, device.Heartbeat{ RuntimeID: s.DeviceID, + CredentialHash: s.credentialHash, DaemonVersion: p.DaemonVersion, ActiveRequests: p.ActiveRequests, HeartbeatTimestamp: p.Timestamp, @@ -550,7 +553,6 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK ToolItems: info.Capabilities.ToolItems, ToolObservations: info.Capabilities.ToolObservations, EnvironmentNone: info.Capabilities.EnvironmentNone, - RemoteEnvironment: info.Capabilities.RemoteEnvironment, LocalEnvironment: info.Capabilities.LocalEnvironment, LocalEnvironmentNetworkPolicy: info.Capabilities.LocalEnvironmentNetworkPolicy, Preparation: info.Capabilities.Preparation, @@ -563,8 +565,6 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK FunctionTools: info.Capabilities.FunctionTools, MCPHTTPTools: info.Capabilities.MCPHTTPTools, MCPHTTPRequired: info.Capabilities.MCPHTTPRequired, - MCPHTTPRemoteEnvironment: info.Capabilities.MCPHTTPRemoteEnvironment, - MCPHTTPRemoteBearerAuth: info.Capabilities.MCPHTTPRemoteBearerAuth, MCPHTTPBearerAuth: info.Capabilities.MCPHTTPBearerAuth, WorkspaceAuthoring: info.Capabilities.WorkspaceAuthoring, }, @@ -644,3 +644,9 @@ func (s *Session) dispatch(env proto.Envelope) { } s.dispatchToSubscriber(env) } + +// AuthenticatedWith compares the credential digest captured by the HTTP upgrade. +// The digest is never accepted from a daemon frame. +func (s *Session) AuthenticatedWith(digest string) bool { + return s.credentialHash != "" && subtle.ConstantTimeCompare([]byte(s.credentialHash), []byte(digest)) == 1 +} diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go index e9c0c86d8..f363b2609 100644 --- a/internal/agentdaemon/gateway/session_test.go +++ b/internal/agentdaemon/gateway/session_test.go @@ -405,7 +405,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { { Kind: "codex", Available: true, - Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, Steering: true, MessageItems: true, ToolItems: true, ToolObservations: true, EnvironmentNone: true, RemoteEnvironment: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true}, + Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, Steering: true, MessageItems: true, ToolItems: true, ToolObservations: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true}, }, }, }) @@ -431,7 +431,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { if opencode.Available || opencode.Version != "missing" || !opencode.Capabilities.Streaming { t.Fatalf("opencode descriptor not converted: %#v", opencode) } - if !byKind["codex"].Capabilities.RemoteEnvironment || claude.Capabilities.RemoteEnvironment || opencode.Capabilities.RemoteEnvironment || !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || opencode.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || opencode.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || opencode.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || opencode.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || opencode.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || opencode.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.ToolItems || claude.Capabilities.ToolItems || opencode.Capabilities.ToolItems || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || opencode.Capabilities.Steering { + if !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || opencode.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || opencode.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || opencode.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || opencode.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || opencode.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || opencode.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.ToolItems || claude.Capabilities.ToolItems || opencode.Capabilities.ToolItems || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || opencode.Capabilities.Steering { t.Fatalf("steering capability not preserved: %#v", byKind) } if !byKind["codex"].Capabilities.MCPHTTPTools || claude.Capabilities.MCPHTTPTools || opencode.Capabilities.MCPHTTPTools { diff --git a/internal/agentdaemon/gateway/subscription_test.go b/internal/agentdaemon/gateway/subscription_test.go index ec4215802..47ad15473 100644 --- a/internal/agentdaemon/gateway/subscription_test.go +++ b/internal/agentdaemon/gateway/subscription_test.go @@ -9,7 +9,7 @@ import ( ) func TestDurableSubscriptionOverflowIsExplicitAndIsolated(t *testing.T) { - s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + s := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) defer s.Close("test finished") sub, err := s.SubscribeDurable("slow") if err != nil { @@ -34,7 +34,7 @@ func TestDurableSubscriptionOverflowIsExplicitAndIsolated(t *testing.T) { } func TestProductSubscriptionRetainsBestEffortBuffer(t *testing.T) { - s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + s := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) defer s.Close("test finished") events, _ := s.Subscribe("product") for range 33 { @@ -57,7 +57,7 @@ func TestProductSubscriptionRetainsBestEffortBuffer(t *testing.T) { func TestSubscriptionCloseAndDispatchAreSerialized(t *testing.T) { for range 100 { - s := NewSession(newFakeConn(), "device", "tenant", "0.2.0", NewRegistry(), nil) + s := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) sub, _ := s.SubscribeDurable("run") var wg sync.WaitGroup wg.Add(3) diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 10fac5043..744f18a67 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -88,12 +88,12 @@ func TestVersionCompatible(t *testing.T) { ok bool }{ {Version, true}, // exact match - {"0.2.99", true}, // patch drift OK - {"0.1.99", false}, // minor drift NOT OK + {"0.3.99", true}, // patch drift OK + {"0.2.99", false}, // minor drift NOT OK {"1.0.0", false}, // major drift NOT OK {"", false}, // missing {"garbage", false}, // unparseable - {"0.1", false}, // truncated + {"0.3", false}, // truncated } for _, tc := range cases { if got := VersionCompatible(tc.client); got != tc.ok { diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 0d2feafec..2ad6c4910 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -35,20 +35,5 @@ func (r PromptRequestPayload) EnvironmentID() string { if r.LocalEnvironment != nil { return r.LocalEnvironment.ID } - if r.RemoteEnvironment != nil { - return r.RemoteEnvironment.ID - } return "" } - -// RemoteEnvironment is a transient execution binding, not a public Environment -// resource. WorkDir remains the harness-local cwd. The selected AgentKind owns -// the native connection protocol; no native selector or configuration-variable name is shared. -// Send only to a peer advertising remote_environment. Never persist or log the -// connection token in Session configuration, events or completion metadata. -type RemoteEnvironment struct { - ID string `json:"id"` - WorkspaceDirectory string `json:"workspace_directory"` - ConnectionURL string `json:"connection_url"` - ConnectionToken string `json:"connection_token"` -} diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 81af45acc..765e61990 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -260,7 +260,6 @@ type AgentKindCapabilities struct { ToolItems bool `json:"tool_items,omitempty"` ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` - RemoteEnvironment bool `json:"remote_environment,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` Preparation bool `json:"preparation,omitempty"` @@ -273,13 +272,11 @@ type AgentKindCapabilities struct { SubagentControl bool `json:"subagent_control,omitempty"` DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` // DurableTurns includes strict resume, completion release and cancellation snapshots. - DurableTurns bool `json:"durable_turns,omitempty"` - FunctionTools bool `json:"function_tools,omitempty"` - MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` - MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` - MCPHTTPRemoteEnvironment bool `json:"mcp_http_remote_environment,omitempty"` - MCPHTTPRemoteBearerAuth bool `json:"mcp_http_remote_bearer_auth,omitempty"` - MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` + DurableTurns bool `json:"durable_turns,omitempty"` + FunctionTools bool `json:"function_tools,omitempty"` + MCPHTTPTools bool `json:"mcp_http_tools,omitempty"` + MCPHTTPRequired bool `json:"mcp_http_required,omitempty"` + MCPHTTPBearerAuth bool `json:"mcp_http_bearer_auth,omitempty"` } // SupportedAgentKind is one daemon-advertised agent engine. Daemons diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index d91b1a88d..adb71b78b 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -76,9 +76,7 @@ type PromptRequestPayload struct { // Nil preserves existing behavior; an empty list explicitly declares no servers. MCPHTTPServers *[]MCPHTTPServer `json:"mcp_http_servers,omitempty"` - // RemoteEnvironment selects independently placed execution through the native adapter. - RemoteEnvironment *RemoteEnvironment `json:"remote_environment,omitempty"` - LocalEnvironment *LocalEnvironment `json:"local_environment,omitempty"` + LocalEnvironment *LocalEnvironment `json:"local_environment,omitempty"` // AgentSessionID is the upstream engine session id to resume. AgentSessionID string `json:"agent_session_id,omitempty"` diff --git a/internal/agentdaemon/proto/preparation.go b/internal/agentdaemon/proto/preparation.go index e28c3ffd2..eccdf9271 100644 --- a/internal/agentdaemon/proto/preparation.go +++ b/internal/agentdaemon/proto/preparation.go @@ -10,7 +10,7 @@ const ( ) // ExecutionPreparePayload reuses execution configuration without accepting input -// or product authoring. The initial private profile requires a remote environment, +// or product authoring. The initial private profile requires a bound local environment, // stable state key, strict resume and completion release. type ExecutionPreparePayload struct { Configuration PromptRequestPayload `json:"configuration"` diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 03cbc5d95..fe0f84363 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -5,7 +5,7 @@ package proto // upgrade query (`version=`) and in the bootstrap HTTP // response; mismatches fail closed at WS upgrade. const ( - Version = "0.2.0" + Version = "0.3.0" ) // VersionCompatible returns true when clientVersion's "X.Y" prefix diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go index aa150eb26..a76ba47e7 100644 --- a/internal/agentdaemon/proto/workspace_read_preparation.go +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -3,7 +3,7 @@ package proto // ValidWorkspaceReadPreparation excludes execution configuration and local paths. // The native adapter supplies temporary state; this request cannot resume or start. func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { - return r.WorkspaceReadOnly && ((r.RemoteEnvironment != nil) != (r.LocalEnvironment != nil)) && r.AgentStateKey != "" && + return r.WorkspaceReadOnly && r.LocalEnvironment != nil && r.AgentStateKey != "" && r.StrictResume && r.ReleaseOnCompletion && r.RunID == "" && r.Prompt == "" && r.ConversationID == "" && r.AgentSessionID == "" && r.WorkDir == "" && !r.RequireExistingNativeSession && !r.WorkspaceAuthoring && !r.DisableExecutionEnvironment && len(r.Attachments) == 0 && diff --git a/packages/codex-executor/Cargo.lock b/packages/codex-executor/Cargo.lock index d63b13874..5cd47d66b 100644 --- a/packages/codex-executor/Cargo.lock +++ b/packages/codex-executor/Cargo.lock @@ -3,8383 +3,562 @@ version = 4 [[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +name = "agents-api-codex-executor" +version = "0.1.0" dependencies = [ - "lazy_static", - "regex", + "rustix", + "serde_json", + "sha2", + "tar", + "tempfile", + "uuid", ] [[package]] -name = "adler2" -version = "2.0.1" +name = "anyhow" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] -name = "aead" -version = "0.5.2" +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "aes" -version = "0.8.4" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", + "generic-array", ] [[package]] -name = "aes-gcm" -version = "0.10.3" +name = "bumpalo" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "agents-api-codex-executor" -version = "0.1.0" -dependencies = [ - "clap", - "codex-api", - "codex-exec-server", - "codex-http-client", - "http", - "rustix", - "serde", - "serde_json", - "sha2", - "tar", - "tempfile", - "tokio", - "url", - "uuid", -] +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "ahash" -version = "0.8.12" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "aho-corasick" -version = "1.1.4" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "memchr", + "libc", ] [[package]] -name = "allocative" -version = "0.3.6" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8cf9afc79c83d514444b55df3935d317da54b1ce3b17a133c646889cc260de8" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "allocative_derive", - "bumpalo", - "ctor", - "hashbrown 0.16.1", - "num-bigint", + "generic-array", + "typenum", ] [[package]] -name = "allocative_derive" -version = "0.3.6" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614043c56c1173b800acb007b81fd0cbc0a0d7d717b71ba705fc2230d0760a23" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "block-buffer", + "crypto-common", ] [[package]] -name = "allocator-api2" -version = "0.2.21" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", + "windows-sys", ] [[package]] -name = "annotate-snippets" -version = "0.9.2" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" -dependencies = [ - "unicode-width", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "anstream" -version = "0.6.21" +name = "filetime" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "cfg-if", + "libc", + "libredox", ] [[package]] -name = "anstyle" -version = "1.0.13" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "anstyle-parse" -version = "0.2.7" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "utf8parse", + "typenum", + "version_check", ] [[package]] -name = "anstyle-query" -version = "1.1.5" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "windows-sys 0.61.2", + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", ] [[package]] -name = "anstyle-wincon" -version = "3.0.11" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] -name = "anyhow" -version = "1.0.103" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "appcontainer_common" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "flatbuffers", - "getrandom 0.2.17", - "learning_mode_core", - "learning_mode_windows", - "process_security_environment_spec", - "sandbox_spec", - "serde", - "serde_json", - "thiserror 2.0.18", - "widestring", - "windows 0.62.2", - "windows-core 0.62.2", - "winreg 0.55.0", - "wxc_common", + "foldhash", ] [[package]] -name = "arc-swap" -version = "1.9.0" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" -dependencies = [ - "rustversion", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "arrayref" -version = "0.3.9" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "arrayvec" -version = "0.7.6" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "zeroize", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "asn1-rs" -version = "0.7.1" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "asn1-rs-derive" -version = "0.6.0" +name = "itoa" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] -name = "asn1-rs-impl" -version = "0.2.0" +name = "js-sys" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "async-channel" -version = "2.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "async-trait" -version = "0.1.89" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "asynk-strim" -version = "0.1.5" +name = "libredox" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "futures-core", - "pin-project-lite", + "bitflags", + "libc", + "redox_syscall", ] [[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic-polyfill" -version = "1.0.3" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "autocfg" -version = "1.5.0" +name = "memchr" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "aws-lc-rs" -version = "1.16.2" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "aws-lc-sys" -version = "0.39.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", + "proc-macro2", + "syn", ] [[package]] -name = "axum" -version = "0.8.8" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "axum-core", - "base64", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", + "unicode-ident", ] [[package]] -name = "axum-core" -version = "0.5.6" +name = "quote" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", + "proc-macro2", ] [[package]] -name = "base64" -version = "0.22.1" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "beef" -version = "0.5.2" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "bit-set" -version = "0.8.0" +name = "redox_syscall" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bit-vec", + "bitflags", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] [[package]] -name = "bitflags" -version = "1.3.2" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "bitflags" -version = "2.13.1" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] -name = "blake3" -version = "1.8.2" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "digest", - "rayon-core", + "serde_core", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "generic-array", + "serde_derive", ] [[package]] -name = "block2" -version = "0.6.2" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "objc2", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "borsh" -version = "1.6.0" +name = "serde_json" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "cfg_aliases 0.2.1", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chardetng" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" -dependencies = [ - "cfg-if", - "encoding_rs", - "memchr", -] - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cidr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clap" -version = "4.5.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim 0.11.1", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "clap_lex" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" - -[[package]] -name = "clatter" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fed49fa357a85c377c0f920e86100f5326111b09ad69f6de684e324e3ad8097" -dependencies = [ - "aes-gcm", - "arrayvec", - "displaydoc", - "getrandom 0.3.4", - "ml-kem", - "rand_core 0.6.4", - "sha2", - "thiserror-no-std", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "cmp_any" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" - -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "codex-api" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "async-channel", - "base64", - "bytes", - "chrono", - "codex-client", - "codex-http-client", - "codex-protocol", - "codex-utils-rustls-provider", - "codex-websocket-client", - "eventsource-stream", - "futures", - "http", - "regex-lite", - "schemars 0.8.22", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tokio-util", - "tracing", - "tungstenite", - "url", - "uuid", -] - -[[package]] -name = "codex-async-utils" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "tokio", - "tokio-util", -] - -[[package]] -name = "codex-client" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-http-client", - "eventsource-stream", - "futures", - "http", - "rand 0.9.3", - "tokio", - "tracing", -] - -[[package]] -name = "codex-config" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "base64", - "codex-execpolicy", - "codex-features", - "codex-file-system", - "codex-git-utils", - "codex-model-provider-info", - "codex-network-proxy", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-path", - "codex-utils-path-uri", - "codex-utils-redacted-string", - "core-foundation 0.9.4", - "dns-lookup", - "dunce", - "futures", - "gethostname", - "indexmap 2.14.0", - "libc", - "multimap", - "prost", - "regex-lite", - "schemars 0.8.22", - "serde", - "serde_ignored", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 2.0.18", - "tokio", - "toml", - "toml_edit 0.24.0+spec-1.1.0", - "tonic", - "tonic-prost", - "tracing", - "wildmatch", - "winapi-util", - "windows-sys 0.52.0", -] - -[[package]] -name = "codex-exec-server" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "arc-swap", - "axum", - "base64", - "bytes", - "clatter", - "codex-api", - "codex-config", - "codex-exec-server-protocol", - "codex-file-system", - "codex-http-client", - "codex-network-proxy", - "codex-otel", - "codex-protocol", - "codex-sandboxing", - "codex-shell-command", - "codex-utils-absolute-path", - "codex-utils-home-dir", - "codex-utils-path-uri", - "codex-utils-pty", - "codex-utils-rustls-provider", - "codex-websocket-client", - "dirs", - "futures", - "http", - "libc", - "prost", - "rustix", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tokio-util", - "toml", - "tracing", - "url", - "uuid", - "windows-sys 0.52.0", -] - -[[package]] -name = "codex-exec-server-protocol" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "base64", - "codex-file-system", - "codex-network-proxy", - "codex-protocol", - "codex-shell-command", - "codex-utils-path-uri", - "serde", - "serde_json", -] - -[[package]] -name = "codex-execpolicy" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "clap", - "codex-utils-absolute-path", - "multimap", - "serde", - "serde_json", - "shlex", - "starlark", - "tempfile", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "codex-extension-items" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-utils-absolute-path", - "schemars 0.8.22", - "serde", - "serde_json", - "ts-rs", -] - -[[package]] -name = "codex-features" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-otel", - "codex-protocol", - "schemars 0.8.22", - "serde", - "toml", - "tracing", -] - -[[package]] -name = "codex-file-system" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "bytes", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-path-uri", - "futures", - "serde", -] - -[[package]] -name = "codex-git-utils" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "chrono", - "codex-file-system", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-path-uri", - "codex-utils-pty", - "futures", - "gix", - "once_cell", - "regex", - "schemars 0.8.22", - "serde", - "similar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "ts-rs", - "walkdir", -] - -[[package]] -name = "codex-http-client" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "bytes", - "codex-utils-rustls-provider", - "futures", - "http", - "native-tls", - "opentelemetry", - "reqwest", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "sha2", - "system-configuration", - "thiserror 2.0.18", - "tokio", - "tracing", - "tracing-opentelemetry", - "windows-sys 0.52.0", - "zstd", -] - -[[package]] -name = "codex-model-provider-info" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-api", - "codex-protocol", - "codex-utils-redacted-string", - "http", - "schemars 0.8.22", - "serde", -] - -[[package]] -name = "codex-network-proxy" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "base64", - "chrono", - "clap", - "codex-utils-absolute-path", - "codex-utils-home-dir", - "codex-utils-rustls-provider", - "globset", - "rama-core", - "rama-http", - "rama-http-backend", - "rama-net", - "rama-socks5", - "rama-tcp", - "rama-tls-rustls", - "rama-unix", - "rand 0.9.3", - "rustls-native-certs", - "schannel", - "security-framework 3.5.1", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "url", - "windows-sys 0.52.0", -] - -[[package]] -name = "codex-otel" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "chrono", - "codex-api", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-string", - "eventsource-stream", - "gethostname", - "http", - "opentelemetry", - "opentelemetry-appender-tracing", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", - "opentelemetry_sdk", - "os_info", - "reqwest", - "serde", - "serde_json", - "strum_macros", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-opentelemetry", - "tracing-subscriber", -] - -[[package]] -name = "codex-protocol" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "chardetng", - "chrono", - "codex-async-utils", - "codex-execpolicy", - "codex-extension-items", - "codex-http-client", - "codex-network-proxy", - "codex-utils-absolute-path", - "codex-utils-image", - "codex-utils-path-uri", - "codex-utils-redacted-string", - "codex-utils-string", - "encoding_rs", - "gix-url", - "globset", - "http", - "icu_decimal", - "icu_locale_core", - "icu_provider", - "landlock", - "quick-xml", - "schemars 0.8.22", - "seccompiler", - "serde", - "serde_json", - "serde_with", - "strum", - "strum_macros", - "sys-locale", - "thiserror 2.0.18", - "tokio", - "tracing", - "ts-rs", - "uuid", - "wildmatch", -] - -[[package]] -name = "codex-sandboxing" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "appcontainer_common", - "codex-network-proxy", - "codex-otel", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-home-dir", - "codex-utils-path-uri", - "codex-utils-pty", - "codex-windows-sandbox", - "dunce", - "libc", - "regex-lite", - "serde_json", - "tokio", - "tracelogging", - "tracing", - "url", - "which", -] - -[[package]] -name = "codex-shell-command" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "base64", - "codex-protocol", - "codex-utils-absolute-path", - "libc", - "once_cell", - "regex", - "serde", - "serde_json", - "shlex", - "tree-sitter", - "tree-sitter-bash", - "tree-sitter-powershell", - "url", - "which", -] - -[[package]] -name = "codex-utils-absolute-path" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "dirs", - "dunce", - "schemars 0.8.22", - "serde", - "ts-rs", -] - -[[package]] -name = "codex-utils-cache" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "lru", - "sha1", - "tokio", -] - -[[package]] -name = "codex-utils-home-dir" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-utils-absolute-path", - "dirs", -] - -[[package]] -name = "codex-utils-image" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "base64", - "codex-utils-cache", - "image", - "mime_guess", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "codex-utils-path" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-utils-absolute-path", - "dunce", - "tempfile", -] - -[[package]] -name = "codex-utils-path-uri" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "base64", - "codex-utils-absolute-path", - "schemars 0.8.22", - "serde", - "thiserror 2.0.18", - "ts-rs", - "url", - "urlencoding", -] - -[[package]] -name = "codex-utils-pty" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "filedescriptor", - "lazy_static", - "libc", - "log", - "portable-pty", - "shared_library", - "tokio", - "winapi", -] - -[[package]] -name = "codex-utils-redacted-string" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "schemars 0.8.22", - "serde", -] - -[[package]] -name = "codex-utils-rustls-provider" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "rustls", -] - -[[package]] -name = "codex-utils-string" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "regex-lite", - "serde", - "serde_json", -] - -[[package]] -name = "codex-websocket-client" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "codex-http-client", - "futures", - "rustls", - "tokio", - "tokio-rustls", - "tokio-tungstenite", - "url", -] - -[[package]] -name = "codex-windows-sandbox" -version = "0.153.4" -source = "git+https://github.com/openai/codex?rev=3d2ee51ca2d5db578f328aa75e20aa22c0197c9a#3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" -dependencies = [ - "anyhow", - "base64", - "chrono", - "codex-otel", - "codex-protocol", - "codex-utils-absolute-path", - "codex-utils-pty", - "codex-utils-string", - "dirs-next", - "dunce", - "glob", - "rand 0.8.6", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing-appender", - "windows 0.58.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-hex" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" -dependencies = [ - "cfg-if", - "cpufeatures", - "proptest", - "serde_core", -] - -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "ctor" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" -dependencies = [ - "link-section", - "linktime-proc-macro", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "debugserver-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" -dependencies = [ - "schemafy", - "serde", - "serde_json", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn 2.0.117", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.13.1", - "objc2", -] - -[[package]] -name = "display_container" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" -dependencies = [ - "either", - "indenter", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dns-lookup" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" -dependencies = [ - "cfg-if", - "libc", - "socket2 0.6.3", - "windows-sys 0.60.2", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dupe" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" -dependencies = [ - "dupe_derive", -] - -[[package]] -name = "dupe_derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "env_filter" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - -[[package]] -name = "env_logger" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" -dependencies = [ - "env_filter", - "log", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" -dependencies = [ - "serde", -] - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom 7.1.3", - "pin-project-lite", -] - -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "faster-hex" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" -dependencies = [ - "heapless 0.8.0", - "serde", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixed_decimal" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" -dependencies = [ - "bitflags 2.13.1", - "rustc_version", -] - -[[package]] -name = "flate2" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs 0.5.5", -] - -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "fastrand", - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generator" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result 0.4.1", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gix" -version = "0.81.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" -dependencies = [ - "gix-actor", - "gix-archive", - "gix-blame", - "gix-commitgraph", - "gix-config", - "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-index", - "gix-lock", - "gix-merge", - "gix-negotiate", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-url", - "gix-utils", - "gix-validate", - "gix-worktree", - "gix-worktree-state", - "gix-worktree-stream", - "nonempty", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-actor" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" -dependencies = [ - "bstr", - "gix-date", - "gix-error", - "winnow", -] - -[[package]] -name = "gix-archive" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" -dependencies = [ - "bstr", - "gix-date", - "gix-error", - "gix-object", - "gix-worktree-stream", -] - -[[package]] -name = "gix-attributes" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-quote", - "gix-trace", - "kstring", - "smallvec", - "thiserror 2.0.18", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-blame" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-diff", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", - "gix-trace", - "gix-traverse", - "gix-worktree", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-chunk" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-command" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" -dependencies = [ - "bstr", - "gix-path", - "gix-quote", - "gix-trace", - "shell-words", -] - -[[package]] -name = "gix-commitgraph" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3196655fd1443f3c58a48c114aa480be3e4e87b393d7292daaa0d543862eb445" -dependencies = [ - "bstr", - "gix-chunk", - "gix-error", - "gix-hash", - "memmap2", - "nonempty", -] - -[[package]] -name = "gix-config" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08939b4c4ed7a663d0e64be9e1e9bdf23a1fb4fcee1febdf449f12229542e50d" -dependencies = [ - "bstr", - "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", - "memchr", - "smallvec", - "thiserror 2.0.18", - "unicode-bom", - "winnow", -] - -[[package]] -name = "gix-config-value" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-path", - "libc", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-date" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39acf819aa9fee65e4838a2eec5cb2506e47ebb89e02a5ab9918196e491571ea" -dependencies = [ - "bstr", - "gix-error", - "itoa", - "jiff", - "smallvec", -] - -[[package]] -name = "gix-diff" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" -dependencies = [ - "bstr", - "gix-command", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", - "imara-diff 0.1.8", - "imara-diff 0.2.0", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-dir" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" -dependencies = [ - "bstr", - "gix-discover", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-discover" -version = "0.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c65bd3330fe0cb9d40d875bf862fd5e8ad6fa4164ddbc4842fbeb889c3f0b2c6" -dependencies = [ - "bstr", - "dunce", - "gix-fs", - "gix-path", - "gix-ref", - "gix-sec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e86d01da904d4a9265def43bd42a18c5e6dc7000a73af512946ba14579c9fbd" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-features" -version = "0.46.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" -dependencies = [ - "bytes", - "crc32fast", - "gix-path", - "gix-trace", - "gix-utils", - "libc", - "once_cell", - "prodash", - "thiserror 2.0.18", - "walkdir", - "zlib-rs 0.6.3", -] - -[[package]] -name = "gix-filter" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash", - "gix-object", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-fs" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a964b4aec683eb0bacb87533defa80805bb4768056371a47ab38b00a2d377b72" -dependencies = [ - "bstr", - "fastrand", - "gix-features", - "gix-path", - "gix-utils", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-glob" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-features", - "gix-path", -] - -[[package]] -name = "gix-hash" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" -dependencies = [ - "faster-hex", - "gix-features", - "sha1-checked", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-hashtable" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" -dependencies = [ - "gix-hash", - "hashbrown 0.16.1", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f915dcf6911e3027537166d34e13f0fe101ed12225178d2ae29cd1272cff26" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-index" -version = "0.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-traverse", - "gix-utils", - "gix-validate", - "hashbrown 0.16.1", - "itoa", - "libc", - "memmap2", - "rustix", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-lock" -version = "21.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054fbd0989700c69dc5aa80bc66944f05df1e15aa7391a9e42aca7366337905f" -dependencies = [ - "gix-tempfile", - "gix-utils", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-merge" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" -dependencies = [ - "bstr", - "gix-command", - "gix-diff", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-quote", - "gix-revision", - "gix-revwalk", - "gix-tempfile", - "gix-trace", - "gix-worktree", - "imara-diff 0.1.8", - "nonempty", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-negotiate" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" -dependencies = [ - "bitflags 2.13.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", -] - -[[package]] -name = "gix-object" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-path", - "gix-utils", - "gix-validate", - "itoa", - "smallvec", - "thiserror 2.0.18", - "winnow", -] - -[[package]] -name = "gix-odb" -version = "0.78.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24833ae9323b4f7079575fb9f961cf9c414b0afbec428a536ab8e7dd93bc002b" -dependencies = [ - "arc-swap", - "gix-features", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", - "gix-quote", - "parking_lot", - "tempfile", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-pack" -version = "0.68.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3484119cd19859d7d7639413c27e192478fa354d3f4ff5f7e3c041e8040f0f4" -dependencies = [ - "clru", - "gix-chunk", - "gix-error", - "gix-features", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-path", - "memmap2", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-packetline" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be19313dcdb7dff75a3ce2f99be00878458295bcc3b6c7f0005591597573345c" -dependencies = [ - "bstr", - "faster-hex", - "gix-trace", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-path" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c31d4373bda7fab9eb01822927b55185a378d6e1bf737e0a54c743ad806658" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-pathspec" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-protocol" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" -dependencies = [ - "bstr", - "gix-date", - "gix-features", - "gix-hash", - "gix-ref", - "gix-shallow", - "gix-transport", - "gix-utils", - "maybe-async", - "nonempty", - "thiserror 2.0.18", - "winnow", -] - -[[package]] -name = "gix-quote" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" -dependencies = [ - "bstr", - "gix-error", - "gix-utils", -] - -[[package]] -name = "gix-ref" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" -dependencies = [ - "gix-actor", - "gix-features", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "memmap2", - "thiserror 2.0.18", - "winnow", -] - -[[package]] -name = "gix-refspec" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" -dependencies = [ - "bstr", - "gix-error", - "gix-glob", - "gix-hash", - "gix-revision", - "gix-validate", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-revision" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" -dependencies = [ - "bitflags 2.13.1", - "bstr", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", - "gix-trace", - "nonempty", -] - -[[package]] -name = "gix-revwalk" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-sec" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f" -dependencies = [ - "bitflags 2.13.1", - "gix-path", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "gix-shallow" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" -dependencies = [ - "bstr", - "gix-hash", - "gix-lock", - "nonempty", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-status" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" -dependencies = [ - "bstr", - "filetime", - "gix-diff", - "gix-dir", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-worktree", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-submodule" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5c3929c5e6821f651d35e8420f72fea3cfafe9fc1e928a61e718b462c72a5" -dependencies = [ - "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-tempfile" -version = "21.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "tempfile", -] - -[[package]] -name = "gix-trace" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0" - -[[package]] -name = "gix-transport" -version = "0.55.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a521e39c6235ce63ed6c001e2dd79818c830b82c3b7b59247ee7b229c39ec9bb" -dependencies = [ - "bstr", - "gix-command", - "gix-features", - "gix-packetline", - "gix-quote", - "gix-sec", - "gix-url", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-traverse" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" -dependencies = [ - "bitflags 2.13.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-url" -version = "0.35.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d28e8af3d42581190da884f013caf254d2fd4d6ab102408f08d21bfa11de6c8d" -dependencies = [ - "bstr", - "gix-path", - "percent-encoding", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-utils" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" -dependencies = [ - "bstr", - "fastrand", - "unicode-normalization", -] - -[[package]] -name = "gix-validate" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec1eff98d91941f47766367cba1be746bab662bad761d9891ae6f7882f7840b" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-worktree" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" -dependencies = [ - "bstr", - "gix-attributes", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate", -] - -[[package]] -name = "gix-worktree-state" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" -dependencies = [ - "bstr", - "gix-features", - "gix-filter", - "gix-fs", - "gix-index", - "gix-object", - "gix-path", - "gix-worktree", - "io-close", - "thiserror 2.0.18", -] - -[[package]] -name = "gix-worktree-stream" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" -dependencies = [ - "gix-attributes", - "gix-error", - "gix-features", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-path", - "gix-traverse", - "parking_lot", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "h2" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32 0.2.1", - "rustc_version", - "serde", - "spin", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32 0.3.1", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.3", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.3", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_decimal" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_decimal_data", - "icu_locale", - "icu_locale_core", - "icu_plurals", - "icu_provider", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_decimal_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" - -[[package]] -name = "icu_locale" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_plurals" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" -dependencies = [ - "fixed_decimal", - "icu_locale", - "icu_plurals_data", - "icu_provider", - "zerovec", -] - -[[package]] -name = "icu_plurals_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imara-diff" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "imara-diff" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" -dependencies = [ - "hashbrown 0.15.5", - "memchr", -] - -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "io-close" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg 0.50.0", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-sys 0.61.2", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "kem" -version = "0.3.0-pre.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" -dependencies = [ - "rand_core 0.6.4", - "zeroize", -] - -[[package]] -name = "kstring" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "landlock" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" -dependencies = [ - "enumflags2", - "libc", - "thiserror 2.0.18", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "learning_mode_core" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "same-file", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 2.0.18", -] - -[[package]] -name = "learning_mode_windows" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "learning_mode_core", - "sha2", - "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", - "wxc_common", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags 2.13.1", - "libc", - "redox_syscall 0.7.0", -] - -[[package]] -name = "link-section" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" - -[[package]] -name = "linktime-proc-macro" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "lock_free_hashtable" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf3631712f5b790675292ff827af269f5d9f920c920b77dc41d0485e3719612" -dependencies = [ - "atomic", - "parking_lot", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "logos" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" -dependencies = [ - "logos-derive", -] - -[[package]] -name = "logos-codegen" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" -dependencies = [ - "beef", - "fnv", - "lazy_static", - "proc-macro2", - "quote", - "regex-syntax", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "logos-derive" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" -dependencies = [ - "logos-codegen", -] - -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" -dependencies = [ - "hashbrown 0.17.1", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lsp-types" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" -dependencies = [ - "bitflags 1.3.2", - "fluent-uri", - "serde", - "serde_json", - "serde_repr", -] - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "matchit" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" - -[[package]] -name = "maybe-async" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "md5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "ml-kem" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" -dependencies = [ - "hybrid-array", - "kem", - "rand_core 0.6.4", - "sha3", - "zeroize", -] - -[[package]] -name = "moka" -version = "0.12.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -dependencies = [ - "serde", -] - -[[package]] -name = "mxc_config_contract" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "mxc_telemetry" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "tracelogging", - "uuid", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nonempty" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", - "serde", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "opentelemetry" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "opentelemetry-appender-tracing" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" -dependencies = [ - "opentelemetry", - "tracing", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "opentelemetry-http" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" -dependencies = [ - "async-trait", - "bytes", - "http", - "opentelemetry", - "reqwest", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" -dependencies = [ - "http", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tonic", - "tracing", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" -dependencies = [ - "base64", - "const-hex", - "opentelemetry", - "opentelemetry_sdk", - "prost", - "serde", - "serde_json", - "tonic", - "tonic-prost", -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" - -[[package]] -name = "opentelemetry_sdk" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "rand 0.9.3", - "thiserror 2.0.18", - "tokio", - "tokio-stream", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "os_info" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" -dependencies = [ - "android_system_properties", - "log", - "nix 0.30.1", - "objc2", - "objc2-foundation", - "objc2-ui-kit", - "serde", - "windows-sys 0.61.2", -] - -[[package]] -name = "pagable" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3658968938a4d1eaa1987e69dcd84b01fb067c5b3416dccc8d71373b6ded6821" -dependencies = [ - "allocative", - "anyhow", - "async-trait", - "blake3", - "bytemuck", - "dashmap", - "dupe", - "either", - "erased-serde 0.4.10", - "fancy-regex", - "indexmap 2.14.0", - "inventory", - "num-bigint", - "once_cell", - "pagable_derive", - "parking_lot", - "postcard", - "regex", - "sequence_trie", - "serde", - "serde_json", - "smallvec", - "sorted_vector_map", - "static_assertions", - "static_interner", - "strong_hash", - "take_mut", - "triomphe", -] - -[[package]] -name = "pagable_derive" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "838d17166587914f4e99353766c29160462b681511f08679545a0d07a0dc9415" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.13.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "crc", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless 0.7.17", - "serde", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "process_security_environment_spec" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "flatbuffers", -] - -[[package]] -name = "prodash" -version = "31.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "proptest" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" -dependencies = [ - "bitflags 2.13.1", - "num-traits", - "rand 0.9.3", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "unarray", -] - -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "psl" -version = "2.1.184" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dc6a90669f481b41cae3005c68efa36bef275b95aa9123a7af7f1c68c6e5b2" -dependencies = [ - "psl-types", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quickcheck" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" -dependencies = [ - "env_logger", - "log", - "rand 0.10.1", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.3", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type 0.1.2", - "nibble_vec", -] - -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type 0.2.0", - "nibble_vec", -] - -[[package]] -name = "rama-core" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" -dependencies = [ - "ahash", - "asynk-strim", - "bytes", - "futures", - "parking_lot", - "pin-project-lite", - "rama-error", - "rama-macros", - "rama-utils", - "serde", - "serde_json", - "tokio", - "tokio-graceful", - "tokio-util", - "tracing", -] - -[[package]] -name = "rama-dns" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" -dependencies = [ - "ahash", - "hickory-resolver", - "rama-core", - "rama-net", - "rama-utils", - "serde", - "tokio", -] - -[[package]] -name = "rama-error" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" - -[[package]] -name = "rama-http" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" -dependencies = [ - "ahash", - "base64", - "bitflags 2.13.1", - "chrono", - "const_format", - "csv", - "http", - "http-range-header", - "httpdate", - "iri-string", - "matchit 0.9.1", - "parking_lot", - "percent-encoding", - "pin-project-lite", - "radix_trie 0.3.0", - "rama-core", - "rama-error", - "rama-http-headers", - "rama-http-types", - "rama-net", - "rama-utils", - "rand 0.9.3", - "serde", - "serde_html_form", - "serde_json", - "tokio", - "uuid", -] - -[[package]] -name = "rama-http-backend" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" -dependencies = [ - "h2", - "pin-project-lite", - "rama-core", - "rama-http", - "rama-http-core", - "rama-http-headers", - "rama-http-types", - "rama-net", - "rama-tcp", - "rama-unix", - "rama-utils", - "tokio", -] - -[[package]] -name = "rama-http-core" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" -dependencies = [ - "ahash", - "atomic-waker", - "futures-channel", - "httparse", - "httpdate", - "indexmap 2.14.0", - "itoa", - "parking_lot", - "pin-project-lite", - "rama-core", - "rama-http", - "rama-http-types", - "rama-utils", - "slab", - "tokio", - "tokio-test", - "want", -] - -[[package]] -name = "rama-http-headers" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" -dependencies = [ - "ahash", - "base64", - "chrono", - "const_format", - "httpdate", - "rama-core", - "rama-error", - "rama-http-types", - "rama-macros", - "rama-net", - "rama-utils", - "rand 0.9.3", - "serde", - "sha1", -] - -[[package]] -name = "rama-http-types" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" -dependencies = [ - "ahash", - "bytes", - "const_format", - "fnv", - "http", - "http-body", - "http-body-util", - "itoa", - "memchr", - "mime", - "mime_guess", - "nom 8.0.0", - "pin-project-lite", - "rama-core", - "rama-error", - "rama-macros", - "rama-utils", - "rand 0.9.3", - "serde", - "serde_json", - "sync_wrapper", - "tokio", -] - -[[package]] -name = "rama-macros" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "rama-net" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" -dependencies = [ - "ahash", - "const_format", - "flume", - "hex", - "ipnet", - "itertools", - "md5", - "nom 8.0.0", - "parking_lot", - "pin-project-lite", - "psl", - "radix_trie 0.3.0", - "rama-core", - "rama-http-types", - "rama-macros", - "rama-utils", - "serde", - "sha2", - "socket2 0.6.3", - "tokio", -] - -[[package]] -name = "rama-socks5" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5468b263516daaf258de32542c1974b7cbe962363ad913dcb669f5d46db0ef3e" -dependencies = [ - "byteorder", - "rama-core", - "rama-net", - "rama-tcp", - "rama-udp", - "rama-utils", - "tokio", -] - -[[package]] -name = "rama-tcp" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" -dependencies = [ - "pin-project-lite", - "rama-core", - "rama-dns", - "rama-http-types", - "rama-net", - "rama-utils", - "rand 0.9.3", - "tokio", -] - -[[package]] -name = "rama-tls-rustls" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "536d47f6b269fb20dffd45e4c04aa8b340698b3509326e3c36e444b4f33ce0d6" -dependencies = [ - "pin-project-lite", - "rama-core", - "rama-http-types", - "rama-net", - "rama-utils", - "rcgen", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "webpki-roots", - "x509-parser", -] - -[[package]] -name = "rama-udp" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36ed05e0ecac73e084e92a3a8b1fbf16fdae8958c506f0f0eada180a2d99eef4" -dependencies = [ - "rama-core", - "rama-net", - "tokio", - "tokio-util", -] - -[[package]] -name = "rama-unix" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" -dependencies = [ - "pin-project-lite", - "rama-core", - "rama-net", - "tokio", -] - -[[package]] -name = "rama-utils" -version = "0.3.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" -dependencies = [ - "const_format", - "parking_lot", - "pin-project-lite", - "rama-macros", - "regex", - "serde", - "smallvec", - "smol_str", - "tokio", - "wildcard", -] - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rcgen" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" -dependencies = [ - "aws-lc-rs", - "pem", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-lite" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "cookie", - "cookie_store", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustyline" -version = "14.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix 0.28.0", - "radix_trie 0.2.1", - "unicode-segmentation", - "unicode-width", - "utf8parse", - "windows-sys 0.52.0", -] - -[[package]] -name = "ryu" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "sandbox_spec" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "flatbuffers", -] - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemafy" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aea5ba40287dae331f2c48b64dbc8138541f5e97ee8793caa7948c1f31d86d5" -dependencies = [ - "Inflector", - "schemafy_core", - "schemafy_lib", - "serde", - "serde_derive", - "serde_json", - "serde_repr", - "syn 1.0.109", -] - -[[package]] -name = "schemafy_core" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41781ae092f4fd52c9287efb74456aea0d3b90032d2ecad272bd14dbbcb0511b" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "schemafy_lib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e953db32579999ca98c451d80801b6f6a7ecba6127196c5387ec0774c528befa" -dependencies = [ - "Inflector", - "proc-macro2", - "quote", - "schemafy_core", - "serde", - "serde_derive", - "serde_json", - "syn 1.0.109", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "seccompiler" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" -dependencies = [ - "libc", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "sequence_trie" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee22067b7ccd072eeb64454b9c6e1b33b61cd0d49e895fd48676a184580e0c3" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_html_form" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" -dependencies = [ - "form_urlencoded", - "indexmap 2.14.0", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "serde_ignored" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serial2" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest", - "sha1", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest", - "keccak", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sorted_vector_map" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bf565ee1681b4473aa5a9d71d807347c28021bd1d8947cb626b02f42a0141f" -dependencies = [ - "itertools", - "quickcheck", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "starlark" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9062e866918dc4c9701c98ac99f7f4fa9e4b3b4edce306e147393bc75458c4fc" -dependencies = [ - "allocative", - "anyhow", - "blake3", - "bumpalo", - "cmp_any", - "dashmap", - "debugserver-types", - "derivative", - "derive_more", - "display_container", - "dupe", - "either", - "erased-serde 0.3.31", - "hashbrown 0.16.1", - "indexmap 2.14.0", - "inventory", - "itertools", - "maplit", - "memoffset", - "num-bigint", - "num-traits", - "once_cell", - "pagable", - "paste", - "ref-cast", - "regex", - "rustyline", - "serde", - "serde_json", - "starlark_derive", - "starlark_map", - "starlark_syntax", - "static_assertions", - "strong_hash", - "strsim 0.10.0", - "textwrap", - "thiserror 2.0.18", -] - -[[package]] -name = "starlark_derive" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797e235eb70936bfa14fabf490bf7453e6f0caaf6b9c56fe4c9aff02aee7e66d" -dependencies = [ - "dupe", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "starlark_map" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234877898fd216af93b2f5798b08cbbdc1a2e8f16a622a258b1db23a61a1c4ba" -dependencies = [ - "allocative", - "dupe", - "equivalent", - "fxhash", - "hashbrown 0.16.1", - "pagable", - "serde", - "strong_hash", -] - -[[package]] -name = "starlark_syntax" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7492c571c531e68099c911cfd909d32659f1cc0910cf3adee9fce66e39d21f14" -dependencies = [ - "allocative", - "annotate-snippets", - "anyhow", - "derivative", - "derive_more", - "dupe", - "logos", - "lsp-types", - "memchr", - "num-bigint", - "num-traits", - "once_cell", - "pagable", - "starlark_map", - "thiserror 2.0.18", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "static_interner" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab44341fbf4deae6e8d5ab450f24e1b34e0b2439d39ac0e9b5215a4e5493263" -dependencies = [ - "equivalent", - "lock_free_hashtable", -] - -[[package]] -name = "streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" - -[[package]] -name = "strong_hash" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0831334aea34390b6b6ec7af0a27f9ee6324ad3a69463e6b240d83d6b7bce9c9" -dependencies = [ - "ref-cast", - "strong_hash_derive", -] - -[[package]] -name = "strong_hash_derive" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace6b48b7c4383a39bd3b966cca41bc999003aab9f690a2f355525c924296928" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "take_mut" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl-no-std" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58e6318948b519ba6dc2b442a6d0b904ebfb8d411a3ad3e07843615a72249758" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "thiserror-no-std" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ad459d94dd517257cc96add8a43190ee620011bb6e6cdc82dafd97dfafafea" -dependencies = [ - "thiserror-impl-no-std", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.3", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-graceful" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" -dependencies = [ - "loom", - "pin-project-lite", - "slab", - "tokio", - "tracing", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.9.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_edit" -version = "0.24.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "tonic" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" -dependencies = [ - "async-trait", - "base64", - "bytes", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "rustls-native-certs", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 2.14.0", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.13.1", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracelogging" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4314470f3f54b29d582ff6776fceb7c819b023141828d559f19c790cee40e94" -dependencies = [ - "tracelogging_macros", -] - -[[package]] -name = "tracelogging_macros" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95e2d891464ff33bc1814c4cbbb251bae7800458b1efdb6ac8b7c01ee6382563" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" -dependencies = [ - "crossbeam-channel", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-opentelemetry" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" -dependencies = [ - "js-sys", - "opentelemetry", - "smallvec", - "tracing", - "tracing-core", - "tracing-log", - "tracing-subscriber", - "web-time", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "tree-sitter" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-bash" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" - -[[package]] -name = "tree-sitter-powershell" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "triomphe" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" -dependencies = [ - "serde", - "stable_deref_trait", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ts-rs" -version = "11.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" -dependencies = [ - "serde_json", - "thiserror 2.0.18", - "ts-rs-macros", - "uuid", -] - -[[package]] -name = "ts-rs-macros" -version = "11.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "termcolor", -] - -[[package]] -name = "tungstenite" -version = "0.27.0" -source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" -dependencies = [ - "bytes", - "data-encoding", - "flate2", - "headers", - "http", - "httparse", - "log", - "rand 0.9.3", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bom" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" - -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "which" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" -dependencies = [ - "env_home", - "rustix", - "winsafe", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "wildcard" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "wildmatch" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core 0.62.2", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core 0.62.2", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core 0.62.2", - "windows-link", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] [[package]] -name = "windows_i686_gnu" -version = "0.53.1" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" +name = "tar" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "typenum" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] -name = "windows_i686_msvc" -version = "0.53.1" +name = "unicode-ident" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "uuid" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "wasm-bindgen-shared" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] [[package]] -name = "winnow" -version = "0.7.14" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "memchr", + "leb128fmt", + "wasmparser", ] [[package]] -name = "winreg" -version = "0.10.1" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "winapi", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "winreg" -version = "0.50.0" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "winreg" -version = "0.55.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winsafe" -version = "0.0.19" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] name = "wit-bindgen" @@ -8409,9 +588,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.14.0", + "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8427,7 +606,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8439,8 +618,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.1", - "indexmap 2.14.0", + "bitflags", + "indexmap", "log", "serde", "serde_derive", @@ -8459,7 +638,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.14.0", + "indexmap", "log", "semver", "serde", @@ -8469,251 +648,8 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wxc_common" -version = "0.8.0" -source = "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4" -dependencies = [ - "base64", - "cidr", - "getrandom 0.2.17", - "libc", - "mxc_config_contract", - "mxc_telemetry", - "semver", - "serde", - "serde_json", - "serde_path_to_error", - "thiserror 2.0.18", - "unicode-general-category", - "url", - "widestring", - "windows 0.62.2", - "windows-core 0.62.2", - "winreg 0.55.0", -] - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "aws-lc-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zlib-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core", -] diff --git a/packages/codex-executor/Cargo.toml b/packages/codex-executor/Cargo.toml index 7fb9ebc4b..708cd4f5b 100644 --- a/packages/codex-executor/Cargo.toml +++ b/packages/codex-executor/Cargo.toml @@ -18,30 +18,15 @@ name = "agents-api-workspace-export" path = "src/bin/export.rs" [dependencies] -codex-api = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } -codex-exec-server = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } -codex-http-client = { git = "https://github.com/openai/codex", rev = "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a" } -clap = { version = "4", features = ["derive"] } -http = "1.3.1" rustix = { version = "=1.1.4", features = ["fs"] } -serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "=0.10.9" tar = { version = "=0.4.46", default-features = false } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } -url = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "=3.27.0" -[patch.crates-io] -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } - -[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } - [profile.dev] debug = 0 diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md index 8f5c3e437..b5b8c051a 100644 --- a/packages/codex-executor/README.md +++ b/packages/codex-executor/README.md @@ -1,20 +1,15 @@ -# Native Codex executor for Agents API +# Runtime filesystem helpers -`agents-api-codex-executor` is a separately named launcher for a third-party Agents -API registry. It embeds the unmodified Codex 0.153.4 executor libraries from commit -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`. Upstream owns registration, Noise, -reconnection, files, process execution and graceful shutdown. This package owns -explicit connection configuration and its service-issued credential. - -This is an optional Linux x86_64 component. The Agents API service and other daemon -adapters build independently. It does not enable public `self_hosted` admission or -establish full Environment compatibility. Stock `codex exec-server` retains its -API-key domain restriction; this launcher does not change that command. +This package provides three bounded local filesystem helpers reused by the V1 +Runtime: directory listing, atomic file installation and workspace output export. +The daemon, native harness, tools and workspace are colocated. The former separate +Codex executor launcher and registry/Noise route are retired; source is retained +in Git history. The package and helper names remain to avoid unrelated renaming. ## Build and installation -Install Rust 1.95.0 with rustfmt and Clippy, a C toolchain, pkg-config and OpenSSL -development headers. Build with the committed Cargo lock: +Install Rust 1.95.0 with rustfmt and Clippy and a C toolchain. Build with the +committed Cargo lock: ```sh make check-agents-executor @@ -22,28 +17,18 @@ make build-agents-executor ``` Build state stays under `~/.parsar/`. `CARGO_HOME`, `CARGO_TARGET_DIR` and -`AGENTS_EXECUTOR_BUILD_DIR` can select existing absolute cache/output locations. -The build copies only this package into its build context; no product or API -service code is needed. The resulting GNU binary requires compatible glibc and -OpenSSL runtime libraries. This is not a portable musl artifact. - -Install the exact official native Codex 0.153.4 package separately. Keep its -platform resource directory intact, including any bundled sandbox helper. -`--codex-bin` must point to its actual native executable, not the npm JavaScript -entry point. The launcher checks its version and creates a stable -`codex-linux-sandbox` alias under its private state directory. Native filesystem, -argv0 and sandbox helper modes execute that official binary; none are copied into -the launcher. System/container sandbox permissions must support the requested -native policy. Do not interpret an unsandboxed command test as sandbox validation. +`AGENTS_EXECUTOR_BUILD_DIR` select absolute cache/output locations. The build +copies only this package and produces Linux x86_64 GNU binaries. Install helpers +at operator-controlled paths outside the writable workspace. Runtime packaging +supplies the selected harness and its native isolation independently. ## Scoped directory helper -The build also emits `agents-api-codex-directory` for the current directory-listing +The build emits `agents-api-codex-directory` for the current directory-listing adapter gap. Install it at an operator-controlled absolute path on the executor, outside the writable workspace. Its three argv values are the authorized absolute workspace root, a relative directory (empty for the root), and a limit of 1–4096. -Invoke it directly through the existing authenticated native process API with a -read-only filesystem policy and restricted network. No shell or model is involved. +Runtime invokes it locally with the authorized filesystem policy. No shell or model is involved. The helper itself is a local program, not an authorization service: the caller must bind the root to the exact authorized owner and validate the installation. @@ -63,8 +48,8 @@ This one-level observation has no order, paging or snapshot guarantee. Renames m leave an operation reading the directory it already opened; workspace replacement and cross-tenant placement remain the caller's responsibility. The helper does not change stock native filesystem methods, create a daemon connection, or enable -public Files. The [native fixture](../../services/agents-api/tests/native/directory/README.md) -qualifies the standalone helper independently of later adapter/public wiring. +public Files. The package tests qualify the bounded helper; public Runtime acceptance separately +checks authorization and Files behavior. ## Scoped output exporter @@ -94,8 +79,8 @@ the helper alone does not enable public Artifacts. The optional `agents-api-codex-write` helper addresses two pinned native write limitations: hard-link targets are modified in place, and base64 encoding a 50 MiB file exceeds the native 64 MiB message bound. Install this helper outside -the writable workspace and invoke it directly through the native process API, -with restricted network and the required helper/runtime reads. The qualified +the writable workspace and invoke it locally through the existing Runtime installer, with restricted network +and the required helper/runtime reads. The qualified installer policy grants write access to one dedicated per-Environment parent containing only workspace and staging; ordinary native tools can write only the workspace. Keep credentials, native history and other Environments outside that @@ -110,8 +95,7 @@ the destination filesystem. Symlink traversal and cross-filesystem replacement are rejected; there is no copy fallback. The former three-argument private CLI is no longer accepted. Stream those bytes in bounded native stdin chunks, followed by their 32-byte binary SHA-256 digest. This is one private frame; -there is no second request on that process. The native process protocol has no -stdin-close method, so the digest terminates the frame without waiting for EOF. +there is no second request on that process. The digest terminates the private frame without waiting for EOF. Extra bytes after the frame are not consumed. A process/write accepted receipt means queued input, not committed file contents. @@ -146,54 +130,10 @@ A missing receipt remains unknown and must not trigger automatic replay. This helper does not fence a replacement owner after remote transport or service loss; public admission still needs operation ownership and recovery handling. -## Connect an executor - -An operator creates an executor principal key with -[`agents-api-environment-key`](../../services/agents-api/README.md#native-executor-transport-prerequisite). -The key may be issued before a Session exists, or optionally restricted to one -existing Environment. Redirect its JSON output to a mode-0600 regular file under -`~/.parsar/` and transfer that credential to its executor. Keep caller, database, -daemon and model-provider credentials outside this compute. - -```sh -~/.parsar/build/agents-executor/agents-api-codex-executor \ - --remote https://agents.example.com \ - --environment-id "$ENVIRONMENT_ID" \ - --credentials "$HOME/.parsar/executor.json" \ - --codex-bin /opt/codex/bin/codex -``` - -The URL is an explicitly trusted service endpoint. HTTPS uses native certificate -and hostname validation; HTTP is accepted only for loopback development. -Userinfo, query strings and fragments are rejected. Native custom CA support uses -`CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE`; no certificate verification bypass is -provided. Native HTTP(S) proxy behavior is retained. - -The JSON requires a canonical nonzero UUID `key_id` and the issued 43-character -base64url `executor_token`. The optional `environment_id` may be omitted or null -for a principal key. If present, it must be a canonical UUID equal to the requested -Environment. The server authorizes the key's stored principal and restrictions; -file metadata supplies local validation only. - -The credential is read once at startup, without ambient OpenAI login/API-key -fallback or raw secret command-line arguments. At the cutover, update the launcher -and replace old credential files together; files without `key_id` are rejected. -Rotate the key through the operator command, replace the private file, and restart -this launcher. Revocation closes the authorized connection through registry -checks; it is not immediate process quiescence. - -Executor state and helper aliases live under -`~/.parsar/codex-executor//` (or the absolute `PARSAR_HOME`). -The native executor's `CODEX_HOME` is scoped there independently of a user's Codex -login. Run the executable under an ordinary service supervisor. SIGINT and SIGTERM -ask the native library to shut down its sessions/processes before returning. -Generated code shares this executor's process user and filesystem visibility; -a private credential file or separate directory is not filesystem isolation. - ## Verification boundaries -`make check-agents-executor` checks configuration, scoped credential handling, -formatting and Clippy. Native transport, TLS, sandbox/helper behavior and actual -model calls require the opt-in [Environment fixtures](../../services/agents-api/tests/native/README.md). -Record their prerequisites and results separately; unit tests and successful -linking alone do not establish a usable executor deployment. +`make check-agents-executor` runs helper tests, formatting and Clippy. The shared +[public acceptance](../../services/agents-api/tests/official_user_runtime.py) +checks execution, Files/Artifacts, cancellation and recovery through an enrolled +Runtime. It requires real model APIs and independently deployed Core/Runtime; +helper unit tests alone do not establish deployment or complete protocol compatibility. diff --git a/packages/codex-executor/src/main.rs b/packages/codex-executor/src/main.rs deleted file mode 100644 index 99e1ee13f..000000000 --- a/packages/codex-executor/src/main.rs +++ /dev/null @@ -1,80 +0,0 @@ -mod options; -mod runtime; - -use clap::Parser; -use codex_api::AuthProvider; -use codex_exec_server::{ - ExecServerError, RemoteEnvironmentConfig, run_remote_environment_until_shutdown, -}; -use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; -use http::{HeaderMap, HeaderValue}; -use std::sync::Arc; - -struct ExecutorAuth(HeaderValue); - -impl AuthProvider for ExecutorAuth { - fn add_auth_headers(&self, headers: &mut HeaderMap) { - headers.insert(http::header::AUTHORIZATION, self.0.clone()); - } -} - -fn main() { - if let Err(message) = run() { - eprintln!("{message}"); - std::process::exit(1); - } -} - -fn run() -> Result<(), &'static str> { - let options = options::Options::parse(); - options.validate()?; - let paths = runtime::prepare(&options)?; - - // No threads exist yet; native helpers must use this executor's private state. - unsafe { std::env::set_var("CODEX_HOME", &paths.codex_home) }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(4) - .enable_all() - .build() - .map_err(|_| "could not start executor runtime")?; - runtime.block_on(async { - let authorization = options.authorization().await?; - let mut terminate = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .map_err(|_| "could not listen for executor shutdown")?; - let config = RemoteEnvironmentConfig::new( - options.remote, - options.environment_id, - Arc::new(ExecutorAuth(authorization)), - HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - ) - .map_err(|_| "invalid executor connection configuration")?; - eprintln!("Starting native executor; press Ctrl-C to stop."); - run_remote_environment_until_shutdown(config, paths.native, async { - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = terminate.recv() => {} - } - }) - .await - .map_err(|error| match error { - ExecServerError::EnvironmentRegistryAuth(_) => { - "registry rejected the executor credential" - } - ExecServerError::EnvironmentRegistryConfig(_) => { - "native registry configuration is invalid" - } - ExecServerError::EnvironmentRegistryHttp { status, .. } => { - eprintln!("Registry HTTP status: {}", status.as_u16()); - "registry rejected the native registration request" - } - ExecServerError::EnvironmentRegistryRequest(_) => { - "registry connection failed; check network, TLS and proxy configuration" - } - ExecServerError::WebSocketConnect { .. } - | ExecServerError::WebSocketConnectTimeout { .. } - | ExecServerError::WebSocketConfiguration(_) => "executor WebSocket connection failed", - _ => "native executor stopped with a protocol or execution error", - }) - }) -} diff --git a/packages/codex-executor/src/options.rs b/packages/codex-executor/src/options.rs deleted file mode 100644 index 3e9c6d494..000000000 --- a/packages/codex-executor/src/options.rs +++ /dev/null @@ -1,128 +0,0 @@ -use clap::Parser; -use codex_exec_server::read_sensitive_file_to_string; -use http::HeaderValue; -use serde::Deserialize; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; -use url::{Host, Url}; -use uuid::Uuid; - -#[derive(Parser)] -#[command( - version, - about = "Agents API executor using Codex 0.153.4 native libraries. Requires the matching native Codex installation." -)] -pub struct Options { - /// Third-party registry HTTPS URL; HTTP is allowed only on loopback for development. - #[arg(long)] - pub remote: String, - /// Canonical Environment UUID issued by Agents API. - #[arg(long)] - pub environment_id: String, - /// Absolute path to mode-0600 JSON from agents-api-environment-key. - #[arg(long)] - pub credentials: PathBuf, - /// Absolute path to the native Codex 0.153.4 executable, with its installation resources intact. - #[arg(long)] - pub codex_bin: PathBuf, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Credential { - key_id: String, - environment_id: Option, - executor_token: String, -} - -impl Options { - pub fn validate(&self) -> Result<(), &'static str> { - validate_remote(&self.remote)?; - let id = Uuid::parse_str(&self.environment_id) - .map_err(|_| "environment ID must be a canonical UUID")?; - if id.to_string() != self.environment_id { - return Err("environment ID must be a canonical UUID"); - } - if !self.credentials.is_absolute() || !self.codex_bin.is_absolute() { - return Err("credentials and native Codex paths must be absolute"); - } - Ok(()) - } - - pub async fn authorization(&self) -> Result { - let metadata = tokio::fs::symlink_metadata(&self.credentials) - .await - .map_err(|_| "could not read executor credential file")?; - if !metadata.is_file() - || metadata.len() > 65536 - || metadata.permissions().mode() & 0o077 != 0 - { - return Err( - "executor credentials require a private regular file (mode 0600, at most 64 KiB)", - ); - } - let json = read_sensitive_file_to_string(&self.credentials) - .await - .map_err(|_| "could not read executor credential file")?; - credential_header(&json, &self.environment_id) - } -} - -fn validate_remote(remote: &str) -> Result<(), &'static str> { - let url = Url::parse(remote).map_err(|_| "invalid executor registry URL")?; - let loopback = match url.host() { - Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(Host::Ipv4(ip)) => ip.is_loopback(), - Some(Host::Ipv6(ip)) => ip.is_loopback(), - None => false, - }; - if url.host().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !(url.scheme() == "https" || (url.scheme() == "http" && loopback)) - { - return Err( - "registry URL requires HTTPS (HTTP only on loopback), without userinfo, query or fragment", - ); - } - Ok(()) -} - -fn credential_header(json: &str, environment: &str) -> Result { - let credential: Credential = serde_json::from_str(json).map_err(|_| { - "invalid executor credential JSON; expected key_id, executor_token and optional environment_id" - })?; - let key_id = Uuid::parse_str(&credential.key_id) - .map_err(|_| "executor credential key ID must be a canonical nonzero UUID")?; - if key_id.is_nil() || key_id.to_string() != credential.key_id { - return Err("executor credential key ID must be a canonical nonzero UUID"); - } - if let Some(environment_id) = credential.environment_id { - let id = Uuid::parse_str(&environment_id) - .map_err(|_| "executor credential Environment ID must be a canonical UUID")?; - if id.to_string() != environment_id { - return Err("executor credential Environment ID must be a canonical UUID"); - } - if environment_id != environment { - return Err("executor credential belongs to a different Environment"); - } - } - if credential.executor_token.len() != 43 - || !credential - .executor_token - .bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') - { - return Err("invalid issued executor credential"); - } - let mut header = HeaderValue::from_str(&format!("Bearer {}", credential.executor_token)) - .map_err(|_| "invalid issued executor credential")?; - header.set_sensitive(true); - Ok(header) -} - -#[cfg(test)] -#[path = "options_tests.rs"] -mod tests; diff --git a/packages/codex-executor/src/options_tests.rs b/packages/codex-executor/src/options_tests.rs deleted file mode 100644 index af8125fae..000000000 --- a/packages/codex-executor/src/options_tests.rs +++ /dev/null @@ -1,171 +0,0 @@ -use super::*; - -const ENVIRONMENT: &str = "20cc9e86-39a0-42ca-a2cd-218c7cd2eced"; -const OTHER_ENVIRONMENT: &str = "1eb47f85-842d-43f2-bbca-42b54cf127cc"; -const KEY_ID: &str = "b626f2e2-4678-434c-a40b-f7be962bc4ea"; -const TOKEN: &str = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; - -fn credential() -> serde_json::Value { - serde_json::json!({"key_id": KEY_ID, "executor_token": TOKEN}) -} - -fn options() -> Options { - Options { - remote: "https://registry.example.test/prefix".into(), - environment_id: ENVIRONMENT.into(), - credentials: "/private/executor.json".into(), - codex_bin: "/opt/codex/bin/codex".into(), - } -} - -#[test] -fn explicit_third_party_https_and_loopback_are_supported() { - for remote in [ - "https://registry.example.test/prefix", - "https://10.0.0.10:8443", - "http://127.0.0.1:8000", - "http://[::1]:8000", - ] { - assert!(validate_remote(remote).is_ok()); - } - for remote in [ - "http://registry.example.test", - "http://10.0.0.10", - "https://user:secret@registry.example.test", - "https://registry.example.test?token=secret", - "https://registry.example.test#secret", - "file:///private/config", - ] { - assert!(validate_remote(remote).is_err()); - } -} - -#[test] -fn ambiguous_environment_and_relative_paths_are_rejected() { - let mut opts = options(); - opts.environment_id = ENVIRONMENT.to_uppercase(); - assert!(opts.validate().is_err()); - opts = options(); - opts.credentials = "executor.json".into(); - assert!(opts.validate().is_err()); -} - -#[test] -fn principal_credential_accepts_omitted_or_null_environment_without_exposing_secret_in_debug() { - let unrestricted = credential(); - let mut null_restriction = credential(); - null_restriction["environment_id"] = serde_json::Value::Null; - for json in [unrestricted, null_restriction] { - for environment in [ENVIRONMENT, OTHER_ENVIRONMENT] { - let header = credential_header(&json.to_string(), environment).unwrap(); - assert_eq!(header.to_str().unwrap(), format!("Bearer {TOKEN}")); - assert!(header.is_sensitive()); - assert!(!format!("{header:?}").contains(TOKEN)); - } - } -} - -#[test] -fn exact_credential_requires_its_canonical_environment() { - let mut json = credential(); - json["environment_id"] = ENVIRONMENT.into(); - let json = json.to_string(); - let header = credential_header(&json, ENVIRONMENT).unwrap(); - assert_eq!(header.to_str().unwrap(), format!("Bearer {TOKEN}")); - assert!(header.is_sensitive()); - assert!(!format!("{header:?}").contains(TOKEN)); - let error = credential_header(&json, OTHER_ENVIRONMENT).unwrap_err(); - assert_eq!( - error, - "executor credential belongs to a different Environment" - ); - assert!(!error.contains(TOKEN)); - for environment in ["invalid".to_owned(), ENVIRONMENT.to_uppercase()] { - let mut json = credential(); - json["environment_id"] = environment.clone().into(); - let error = credential_header(&json.to_string(), &environment).unwrap_err(); - assert!(!error.contains(TOKEN)); - } -} - -#[test] -fn old_credential_without_key_id_fails_clearly() { - let json = serde_json::json!({"environment_id": ENVIRONMENT, "executor_token": TOKEN}); - let error = credential_header(&json.to_string(), ENVIRONMENT).unwrap_err(); - assert!(error.contains("key_id")); - assert!(!error.contains(TOKEN)); -} - -#[test] -fn key_id_requires_a_canonical_nonzero_uuid_without_exposing_invalid_values() { - for key_id in [ - serde_json::Value::Null, - serde_json::json!(123), - "".into(), - "00000000-0000-0000-0000-000000000000".into(), - KEY_ID.to_uppercase().into(), - KEY_ID.replace('-', "").into(), - TOKEN.into(), - ] { - let mut json = credential(); - json["key_id"] = key_id; - let error = credential_header(&json.to_string(), ENVIRONMENT).unwrap_err(); - assert!(!error.contains(TOKEN)); - } -} - -#[test] -fn malformed_json_unknown_fields_and_invalid_tokens_do_not_expose_secrets() { - let mut unknown_field = credential(); - unknown_field[TOKEN] = TOKEN.into(); - let json = credential().to_string(); - for invalid in [ - format!("{{ not json {TOKEN}"), - unknown_field.to_string(), - json.replace(TOKEN, "sk-not-an-issued-executor-key"), - json.replace(TOKEN, &"a".repeat(42)), - json.replace(TOKEN, &"a".repeat(44)), - json.replace(TOKEN, &format!("{}+", "a".repeat(42))), - json.replace("executor_token", "api_key"), - ] { - let error = credential_header(&invalid, ENVIRONMENT).unwrap_err(); - assert!(!error.contains(TOKEN)); - } -} - -#[tokio::test] -async fn only_private_regular_credential_files_are_accepted() { - use std::os::unix::fs::{PermissionsExt, symlink}; - - let base = PathBuf::from(std::env::var_os("HOME").unwrap()) - .join(".parsar") - .join("executor-credential-tests") - .join(Uuid::new_v4().to_string()); - std::fs::create_dir_all(&base).unwrap(); - let mut opts = options(); - opts.credentials = base.join("credential.json"); - assert!(opts.authorization().await.is_err()); - let json = credential().to_string(); - std::fs::write(&opts.credentials, &json).unwrap(); - std::fs::set_permissions(&opts.credentials, std::fs::Permissions::from_mode(0o644)).unwrap(); - assert!(opts.authorization().await.is_err()); - std::fs::set_permissions(&opts.credentials, std::fs::Permissions::from_mode(0o600)).unwrap(); - assert!(opts.authorization().await.is_ok()); - let link = base.join("alias.json"); - symlink(&opts.credentials, &link).unwrap(); - opts.credentials = link; - assert!(opts.authorization().await.is_err()); - opts.credentials = base.clone(); - assert!(opts.authorization().await.is_err()); - opts.credentials = base.join("credential.json"); - let mut padded_json = json; - padded_json.push_str(&" ".repeat(65536 - padded_json.len())); - std::fs::write(&opts.credentials, &padded_json).unwrap(); - assert!(opts.authorization().await.is_ok()); - padded_json.push(' '); - std::fs::write(&opts.credentials, &padded_json).unwrap(); - let error = opts.authorization().await.unwrap_err(); - assert!(!error.contains(TOKEN)); - assert!(error.contains("64 KiB")); - std::fs::remove_dir_all(base).unwrap(); -} diff --git a/packages/codex-executor/src/runtime.rs b/packages/codex-executor/src/runtime.rs deleted file mode 100644 index f9a78d75a..000000000 --- a/packages/codex-executor/src/runtime.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::options::Options; -use codex_exec_server::ExecServerRuntimePaths; -use std::io::Read; -use std::os::unix::fs::{DirBuilderExt, symlink}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -pub struct RuntimePaths { - pub codex_home: PathBuf, - pub native: ExecServerRuntimePaths, -} - -pub fn prepare(options: &Options) -> Result { - if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { - return Err("this executor currently supports Linux x86_64 only"); - } - let base = match std::env::var_os("PARSAR_HOME") { - Some(path) => PathBuf::from(path), - None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is required")?).join(".parsar"), - }; - if !base.is_absolute() { - return Err("executor state directory must be absolute"); - } - let state = base.join("codex-executor").join(&options.environment_id); - let codex_home = state.join("codex"); - private_directory(&codex_home)?; - let binary = options - .codex_bin - .canonicalize() - .map_err(|_| "native Codex executable is unavailable")?; - let mut magic = [0u8; 4]; - std::fs::File::open(&binary) - .and_then(|mut file| file.read_exact(&mut magic)) - .map_err(|_| "could not read native Codex executable")?; - if magic != *b"\x7fELF" { - return Err("--codex-bin requires the native ELF executable, not a wrapper"); - } - let version = Command::new(&binary) - .arg("--version") - .env("CODEX_HOME", &codex_home) - .output() - .map_err(|_| "could not verify native Codex executable")?; - if !version.status.success() - || String::from_utf8_lossy(&version.stdout).trim() != "codex-cli 0.153.4" - { - return Err("native Codex 0.153.4 is required"); - } - let sandbox = state.join("codex-linux-sandbox"); - match std::fs::read_link(&sandbox) { - Ok(target) if target == binary => {} - Ok(_) => return Err("executor sandbox helper points to another installation"), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - symlink(&binary, &sandbox).map_err(|_| "could not create executor sandbox helper")?; - } - Err(_) => return Err("executor sandbox helper is unavailable"), - } - let native = ExecServerRuntimePaths::new(binary, Some(sandbox)) - .map_err(|_| "invalid native helper paths")?; - Ok(RuntimePaths { codex_home, native }) -} - -fn private_directory(path: &Path) -> Result<(), &'static str> { - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(path) - .map_err(|_| "could not create private executor state") -} diff --git a/packages/codex-harness/README.md b/packages/codex-harness/README.md deleted file mode 100644 index ded5a24ae..000000000 --- a/packages/codex-harness/README.md +++ /dev/null @@ -1,221 +0,0 @@ -# Private Codex harness artifact - -`parsar-codex-harness` is an opt-in Linux amd64 executable. It embeds the pinned -Codex raw stdio runner and exposes private remote metadata and bounded reads through -the runner's own `EnvironmentManager`. The existing Go `JSONRPCClient` owns the -child and native execution transport. The file socket is local control IPC, -not another executor/Noise connection. Default daemon installation and public -feature admission are unchanged. - -## Source and patch ownership - -Parsar maintains this integration artifact. It is not the stock upstream binary. -`source.json` pins Codex 0.153.4 at -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, Rust 1.95.0, the manager hook and a -separate named-binary manifest overlay. A separately hashed bounded-read patch -exposes one native operation without exposing the general RPC client. The hook's canonical copy is -`patches/manager-exposure.patch`; the older native qualifications reference the -same file. Maintain its qualification and hash with every deliberate change. -Replace the hook when an equivalent maintained upstream entrypoint is selected -and independently accepted; never silently change the native pin. - -Preparation exports that exact Git commit, ignoring checkout modifications. It -checks the original lock, normalizes only the 149 upstream workspace package -versions, checks the resulting lock, applies the hashed patches and injects -`src/` into `codex-rs/app-server/parsar-harness/`. The named binary uses existing -app-server dependencies. No dependency resolution, client dependency or -third-party version change is part of the overlay. Mismatched identities fail. - -## Build and checks - -Install Rust 1.95.0 with rustfmt and Clippy, Python 3.10+, Git, tar, a C toolchain, -pkg-config and OpenSSL development headers on Linux amd64. Supply an existing -official Codex Git checkout containing the pinned commit: - -```sh -export AGENTS_HARNESS_NATIVE_SOURCE="$HOME/.parsar/references/codex-native" -make check-agents-harness -make check-agents-harness-native -make build-agents-harness -``` - -`make check` includes the lightweight packaging checks. The explicit native check -prepares a fresh export and runs the binary's locked unit tests, formatting and -Clippy. Native checking and a release build are required for artifact changes; -they are intentionally separate from the ordinary local gate. CI runs both on -affected paths. Real executor/provider acceptance is separate from all build checks. - -Builds and caches stay below `~/.parsar`. `CARGO_HOME`, `CARGO_TARGET_DIR` and -`AGENTS_HARNESS_BUILD_DIR` may override their defaults only within that root. -`RUSTUP_TOOLCHAIN` may select an installed alias; the build verifies that its -compiler reports exactly Rust 1.95.0. -The default output is `~/.parsar/build/agents-harness/parsar-codex-harness` beside -`provenance.json`. Provenance records the native commit, manifest, patches, -injected sources, prepared lock, toolchain and artifact hash. Acceptance must also -record the exact stock helper hash and check execution without the prepared source -tree present. Build provenance alone does not establish runtime compatibility. - -## Private startup contract - -The operator supplies these environment variables to the child: - -| Variable | Meaning | -| --- | --- | -| `PARSAR_CODEX_HARNESS_NATIVE` | Absolute path to the stock native 0.153.4 helper | -| `PARSAR_CODEX_HARNESS_ENVIRONMENT` | One canonical remote Environment UUID | -| `PARSAR_CODEX_HARNESS_WORKSPACE` | Absolute workspace path on that executor | -| `PARSAR_CODEX_HARNESS_IPC_ROOT` | New private directory below the caller's `~/.parsar` | - -The wrapper accepts the existing `-c` overrides and `app-server --stdio` with -`--enable`/`--disable` features. Unsupported options fail explicitly. Native -configuration and `CODEX_HOME` remain native concerns; provider credentials must -not be added to wrapper arguments. Public requests cannot select local process, -helper or socket targets. - -The private `--workspace-read-only` preparation mode uses a temporary `CODEX_HOME` -and native loader overrides to exclude system/managed execution configuration, -user/project configuration and plugin startup. Native security requirements stay -enabled. The legacy mixed `/etc/codex/managed_config.toml` profile is explicitly -rejected rather than silently dropping its enforced constraints. The daemon -supplies only process/transport environment variables and the -exact remote binding; model/MCP credentials are excluded. This mode is for unused -preparation and directory reads; daemon and adapter reject Start. It does not grant -public Files access or prove remote mutation retirement. Ordinary native execution -keeps its existing configuration loading. - -The endpoint is `files.sock` within the new `0700` IPC directory, with mode `0600` -and a same-UID peer check. Existing directories or socket paths are not overwritten. -Each bounded connection carries one JSON line with `environment_id` and a relative -`path`. The identity must match startup configuration, and the manager entry must -be remote and ready. Startup also matches the operator UUID to the native registry -Environment variable. The native manager uses its fixed `remote` key, independently -of that UUID. A response reports native metadata or a safe error. There is -no local filesystem fallback. Omitting `operation` selects metadata. An explicit -`operation: "read"` requires an integer `max_bytes` from 1 through 8 MiB; this is a -private adapter bound, not a public protocol limit. The response has `read` with -base64 `data_base64`, `truncated` and `close_acknowledged: true`. Reads use native -blocks of at most 1 MiB and one extra byte to distinguish an exact-bound file from -a truncated prefix. No snapshot consistency is promised for a changing file. -An explicit `operation: "list_directory"` requires `max_entries` from 1 through -4096 and allows an empty path for the bound workspace root. The native process -backend invokes the executor's qualified `agents-api-codex-directory` helper with -explicit argv, a read-only Linux sandbox and restricted network. Set the daemon's -operator-only `PARSAR_CODEX_DIRECTORY_HELPER` to its clean absolute executor path; -the daemon freezes it as `PARSAR_CODEX_HARNESS_DIRECTORY_HELPER`, overriding any -caller environment value. Missing/invalid selection rejects directory operations -without changing preparation, byte reads or model execution. The installation must -remain trusted and outside the workspace's writable tree, including aliases. -`directory` contains `entries` (`name`, `kind`, nullable `size_bytes`) and explicit -`truncated`, without ordering, pagination or snapshot guarantees. The helper uses -bounded descriptor-scoped enumeration, including symlinks without following them. -Only successful exit/output close with a complete version-1 frame yields a result. -The native retained output window is 1 MiB; event-sequence gaps reject lost output -rather than accepting a parseable tail. Aggregate output is capped at 4 MiB. -Rejection before closure terminates and drains the process; unknown cleanup fails -the existing owner without replay. These are private adapter semantics and do not -qualify a public Files endpoint. - -### Private file writes - -`operation: "write"` requires integer `size_bytes` from 0 through 50 MiB, a -nonempty clean relative path, and exactly that many raw bytes after the JSON -header. `max_bytes` and `max_entries` must be absent or null. One connection carries -one request; no input EOF is needed, and extra bytes do not create another request. -The complete bounded body is held in memory before native dispatch. Incomplete -input closes the connection without starting a remote operation. - -This endpoint requires both `PARSAR_CODEX_HARNESS_WRITE_HELPER` and -`PARSAR_CODEX_HARNESS_STAGING`, frozen before native dotenv loading. Their values -are clean absolute executor paths. Workspace and staging must be different -siblings below one non-root Environment parent; the helper must be outside that -parent. `--workspace-read-only` removes write admission even when these variables -are configured. No daemon capability or public request enables this profile. - -The operator must qualify the [installer placement requirements](../codex-executor/README.md#scoped-file-installer): -staging and its ancestors cannot be writable through native tools or aliases; -credentials, native history and other Environments stay outside the shared parent. -These path checks do not attest remote mounts or isolation. The native installer -gets one writable parent covering workspace and staging, minimal runtime/helper -reads, restricted network and a required Linux sandbox. Do not split workspace -and staging into separate sandbox bind mounts: cross-mount rename must fail -without a copying fallback. - -The same captured native process receives at most 64 KiB per stdin write followed -by the raw SHA-256 trailer. Native input retries retain the native request identity; -this wrapper does not replay or start a replacement. A complete version-1 commit -receipt, exact size, exit zero and output closure without sequence gaps returns -`{"write":{"size_bytes":N,"committed":true}}`. A confirmed pre-commit helper -failure returns `native_error`. Missing, invalid or unknown receipts and native -transport failures stop the owner as unresolved, even if the process exited or -termination was requested. Detached callers retain the same bounded native wait. -This does not establish successor safety, durable recovery, snapshot stability or -public Files.create. The private [native fixture](../../services/agents-api/tests/native/write/README.md) -qualifies the transport separately from real-model execution regression. - -The bounded-read hook captures one native RPC connection before opening a handle. -Open, ordered block reads and cleanup use that exact connection without recovery -or replay. A successful result requires a successful close response after all -reads. A server rejection settles that particular request; it does not establish -successful close. Ambiguous transport/protocol outcomes and unconfirmed close -stop the owner without delivering partial bytes or admitting another request. -Closing a replacement connection cannot settle an old handle. The native stock -stream remains unchanged; its asynchronous Drop cleanup is not used as a receipt. - -Startup freezes the operator binding before calling native `arg0_dispatch`. This -preserves native `CODEX_HOME/.env` credential loading and helper dispatch before -threads start, without letting dotenv replace private selectors. The native alias -guard lives until runtime teardown; explicit child re-execution uses the pinned -stock helper. - -Metadata, reads and directory requests share one ten-second deadline. Write -headers have the same limit; a valid write has sixty seconds total from connection -acceptance for its bounded body, native transfer and receipt. A stalled frame or response writer -closes its connection. Caller disconnect does not cancel an admitted native wait. -When the raw runner ends, stop new admission and pending frames, then drain the -admitted operation within its original deadline. A stopped owner need not deliver -the result to the caller. Preserve runner failures after a successful drain, and -report an unresolved drain as failure even if the runner exited normally. This -only accounts for the native future; it does not prove remote effect retirement. -The existing daemon RPC `Close` can force-kill this child after its 250 ms grace, -interrupting the drain. A daemon/Core file consumer must reconcile that boundary -and retain uncertainty before treating release as operation settlement; this -artifact change does not alter the RPC's existing local-reap contract. -If the native operation has not settled by the deadline, -the artifact exits with an error and closes admission; dropping the native wait -does not cancel remote work. Recovery must retain that uncertainty and must not -infer remote retirement from this local failure. Runtime shutdown waits at most -one second for blocking tasks, including native stdin, so a caller keeping its -input pipe open still observes local process exit. This is not a remote cleanup -guarantee. - -## Acceptance limits - -Qualification must use this final binary through the existing Go RPC caller and -an actual authenticated remote executor. Native execution creates a file; metadata -and bounded bytes are observed while idle, during execution, after cancellation -and following fresh process history continuation. Synthetic binary and empty files -supplement native-created files to check byte fidelity, exact bounds and truncation. -Each successful read must include its ordered close acknowledgment. Controlled tests cover startup/EOF, identity errors, -socket collision, oversized frames, stalled peers and early runner exit. Preserve -failed evidence and distinguish local child exit from remote mutation retirement. - -Raw stdio avoids a typed-notification parser and preserves the native transport. -This does not mean the Go adapter stores unknown notifications or that every -existing RPC queue/write path is production-qualified. IPC frame, concurrency and -deadline bounds do not establish general native filesystem resource limits. -Private metadata and byte reads do not prove path isolation. Directory access needs -separate actual native permission/isolation acceptance. None of these observations -establishes public Files semantics, a reusable idle -owner, Core authority, successor safety or complete output fidelity. These remain -separate admission and acceptance work. - -## Opt-in adapter launch - -Set `PARSAR_CODEX_HARNESS_BIN` to the absolute integrated artifact path and keep -`PARSAR_CODEX_BIN` pointing to the stock helper. The daemon uses the artifact only -for validated remote Codex preparations. It supplies the frozen binding and a new -private socket directory, retains the existing Prepared/Session/RPC lifecycle, -and cleans the directory after the child is reaped. Nonremote Codex and Claude -keep their current launch paths. No wrapper or new public capability is required. -The private file socket remains operator infrastructure; public Files and -Core ownership/retirement gates are separate work. diff --git a/packages/codex-harness/patches/artifact-target.patch b/packages/codex-harness/patches/artifact-target.patch deleted file mode 100644 index 721ebc2de..000000000 --- a/packages/codex-harness/patches/artifact-target.patch +++ /dev/null @@ -1,13 +0,0 @@ ---- a/codex-rs/app-server/Cargo.toml -+++ b/codex-rs/app-server/Cargo.toml -@@ -15,6 +15,10 @@ - [[bin]] - name = "exec-server" - path = "src/bin/exec_server.rs" -+ -+[[bin]] -+name = "parsar-codex-harness" -+path = "parsar-harness/main.rs" - - [lib] - name = "codex_app_server" diff --git a/packages/codex-harness/patches/bounded-read.patch b/packages/codex-harness/patches/bounded-read.patch deleted file mode 100644 index 8953d9ea6..000000000 --- a/packages/codex-harness/patches/bounded-read.patch +++ /dev/null @@ -1,551 +0,0 @@ -diff --git a/codex-rs/exec-server/src/bounded_file_read.rs b/codex-rs/exec-server/src/bounded_file_read.rs -new file mode 100644 -index 0000000..c3e8535 ---- /dev/null -+++ b/codex-rs/exec-server/src/bounded_file_read.rs -@@ -0,0 +1,183 @@ -+use std::io; -+use std::sync::Arc; -+ -+use codex_utils_path_uri::PathUri; -+use uuid::Uuid; -+ -+use super::ExecServerClient; -+use super::ExecServerError; -+use super::LazyRemoteExecServerClient; -+use crate::FILE_READ_CHUNK_SIZE; -+use crate::protocol::FS_CLOSE_METHOD; -+use crate::protocol::FS_OPEN_METHOD; -+use crate::protocol::FS_READ_BLOCK_METHOD; -+use crate::protocol::FsCloseParams; -+use crate::protocol::FsCloseResponse; -+use crate::protocol::FsOpenParams; -+use crate::protocol::FsOpenResponse; -+use crate::protocol::FsReadBlockParams; -+use crate::protocol::FsReadBlockResponse; -+use crate::remote_file_system::map_remote_error; -+use crate::rpc::RpcClient; -+ -+pub const MAX_BOUNDED_FILE_READ_BYTES: usize = 8 * 1024 * 1024; -+ -+#[derive(Debug)] -+pub struct BoundedFileRead { -+ pub bytes: Vec, -+ pub truncated: bool, -+} -+ -+#[derive(Debug, thiserror::Error)] -+#[error("{error}")] -+pub struct BoundedFileReadError { -+ #[source] -+ pub error: io::Error, -+ pub unsettled: bool, -+} -+ -+impl BoundedFileReadError { -+ fn local(message: &str) -> Self { -+ Self { -+ error: io::Error::new(io::ErrorKind::InvalidInput, message), -+ unsettled: false, -+ } -+ } -+ -+ fn rpc(error: ExecServerError) -> Self { -+ let unsettled = !matches!(error, ExecServerError::Server { .. }); -+ Self { -+ error: map_remote_error(error), -+ unsettled, -+ } -+ } -+} -+ -+impl LazyRemoteExecServerClient { -+ pub(crate) async fn read_file_bounded( -+ &self, -+ path: &PathUri, -+ max_bytes: usize, -+ ) -> Result { -+ validate_bound(max_bytes)?; -+ let client = self -+ .fail_fast() -+ .get() -+ .await -+ .map_err(|error| BoundedFileReadError { -+ error: map_remote_error(error), -+ unsettled: false, -+ })?; -+ client.read_file_bounded(path, max_bytes).await -+ } -+} -+ -+fn validate_bound(max_bytes: usize) -> Result<(), BoundedFileReadError> { -+ if !(1..=MAX_BOUNDED_FILE_READ_BYTES).contains(&max_bytes) { -+ return Err(BoundedFileReadError::local( -+ "file read bound must be between 1 and 8388608 bytes", -+ )); -+ } -+ Ok(()) -+} -+ -+impl ExecServerClient { -+ async fn read_file_bounded( -+ &self, -+ path: &PathUri, -+ max_bytes: usize, -+ ) -> Result { -+ validate_bound(max_bytes)?; -+ let rpc = self -+ .rpc_client_without_recovery() -+ .map_err(|error| BoundedFileReadError { -+ error: map_remote_error(error), -+ unsettled: false, -+ })?; -+ let handle_id = Uuid::new_v4().simple().to_string(); -+ let opened: Result = self -+ .call_rpc( -+ &rpc, -+ FS_OPEN_METHOD, -+ &FsOpenParams { -+ handle_id: handle_id.clone(), -+ path: path.clone(), -+ sandbox: None, -+ }, -+ ) -+ .await; -+ let result = match opened { -+ Ok(response) if response.handle_id == handle_id => { -+ self.read_bounded_blocks(&rpc, &handle_id, max_bytes).await -+ } -+ Ok(_) => Err(BoundedFileReadError { -+ error: io::Error::new( -+ io::ErrorKind::InvalidData, -+ "file open returned an unexpected handle", -+ ), -+ unsettled: true, -+ }), -+ Err(error @ ExecServerError::Server { .. }) => { -+ return Err(BoundedFileReadError::rpc(error)); -+ } -+ Err(error) => Err(BoundedFileReadError::rpc(error)), -+ }; -+ // Keep cleanup on the original connection, even if the Environment has recovered. -+ let closed: Result = self.map_rpc_call_result( -+ rpc.call_for_cleanup(FS_CLOSE_METHOD, &FsCloseParams { handle_id }) -+ .await, -+ ); -+ match closed { -+ Ok(_) => result, -+ Err(error) => Err(BoundedFileReadError { -+ error: map_remote_error(error), -+ unsettled: true, -+ }), -+ } -+ } -+ -+ async fn read_bounded_blocks( -+ &self, -+ rpc: &Arc, -+ handle_id: &str, -+ max_bytes: usize, -+ ) -> Result { -+ let limit = max_bytes + 1; -+ let mut bytes = Vec::with_capacity(limit); -+ loop { -+ let len = FILE_READ_CHUNK_SIZE.min(limit - bytes.len()); -+ let response: FsReadBlockResponse = self -+ .call_rpc( -+ rpc, -+ FS_READ_BLOCK_METHOD, -+ &FsReadBlockParams { -+ handle_id: handle_id.to_owned(), -+ offset: bytes.len() as u64, -+ len, -+ }, -+ ) -+ .await -+ .map_err(BoundedFileReadError::rpc)?; -+ let chunk = response.chunk.into_inner(); -+ if chunk.len() > len || (chunk.is_empty() && !response.eof) { -+ return Err(BoundedFileReadError { -+ error: io::Error::new( -+ io::ErrorKind::InvalidData, -+ "file read returned an invalid block", -+ ), -+ unsettled: true, -+ }); -+ } -+ bytes.extend_from_slice(&chunk); -+ if response.eof || bytes.len() == limit { -+ let truncated = bytes.len() > max_bytes; -+ bytes.truncate(max_bytes); -+ return Ok(BoundedFileRead { bytes, truncated }); -+ } -+ } -+ } -+} -+ -+#[cfg(test)] -+#[path = "bounded_file_read_tests.rs"] -+mod tests; -diff --git a/codex-rs/exec-server/src/bounded_file_read_tests.rs b/codex-rs/exec-server/src/bounded_file_read_tests.rs -new file mode 100644 -index 0000000..2d5f568 ---- /dev/null -+++ b/codex-rs/exec-server/src/bounded_file_read_tests.rs -@@ -0,0 +1,282 @@ -+use codex_exec_server_protocol::JSONRPCError; -+use codex_exec_server_protocol::JSONRPCMessage; -+use codex_exec_server_protocol::JSONRPCRequest; -+use codex_exec_server_protocol::JSONRPCResponse; -+use codex_exec_server_protocol::RequestId; -+use serde_json::Value; -+use serde_json::json; -+use tokio::sync::mpsc; -+use tokio::sync::watch; -+use tokio::time::Duration; -+use tokio::time::timeout; -+ -+use super::*; -+use crate::ExecServerClientConnectOptions; -+use crate::client::ConnectionStatus; -+use crate::connection::JsonRpcConnection; -+use crate::connection::JsonRpcConnectionEvent; -+use crate::connection::JsonRpcTransport; -+use crate::rpc::not_found; -+ -+struct Peer { -+ requests: mpsc::Receiver, -+ responses: mpsc::Sender, -+ _disconnected: watch::Sender, -+} -+ -+fn connection() -> (JsonRpcConnection, Peer) { -+ let (outgoing_tx, requests) = mpsc::channel(8); -+ let (responses, incoming_rx) = mpsc::channel(8); -+ let (disconnected, disconnected_rx) = watch::channel(false); -+ ( -+ JsonRpcConnection { -+ outgoing_tx, -+ incoming_rx, -+ disconnected_rx, -+ task_handles: Vec::new(), -+ transport: JsonRpcTransport::Plain, -+ }, -+ Peer { -+ requests, -+ responses, -+ _disconnected: disconnected, -+ }, -+ ) -+} -+ -+impl Peer { -+ async fn request(&mut self, method: &str) -> JSONRPCRequest { -+ let message = timeout(Duration::from_secs(2), self.requests.recv()) -+ .await -+ .expect("request deadline") -+ .expect("request channel"); -+ let JSONRPCMessage::Request(request) = message else { -+ panic!("expected request: {message:?}") -+ }; -+ assert_eq!(request.method, method); -+ request -+ } -+ -+ async fn respond(&self, id: RequestId, result: Value) { -+ self.responses -+ .send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Response( -+ JSONRPCResponse { id, result }, -+ ))) -+ .await -+ .expect("send response"); -+ } -+ -+ async fn reject(&self, id: RequestId) { -+ self.responses -+ .send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Error( -+ JSONRPCError { -+ id, -+ error: not_found("missing".to_owned()), -+ }, -+ ))) -+ .await -+ .expect("send rejection"); -+ } -+} -+ -+async fn connected() -> (ExecServerClient, Peer) { -+ let (connection, mut peer) = connection(); -+ let bootstrap = tokio::spawn(async move { -+ let request = peer.request("initialize").await; -+ peer.respond( -+ request.id, -+ json!({"sessionId":"bounded-reader","environmentInfo":null}), -+ ) -+ .await; -+ assert!(matches!( -+ peer.requests.recv().await, -+ Some(JSONRPCMessage::Notification(_)) -+ )); -+ peer -+ }); -+ let client = ExecServerClient::connect(connection, ExecServerClientConnectOptions::default()) -+ .await -+ .expect("connect native client"); -+ (client, bootstrap.await.expect("bootstrap")) -+} -+ -+fn path() -> PathUri { -+ PathUri::parse("file:///workspace/artifact.bin").expect("file URI") -+} -+ -+#[tokio::test] -+async fn bounded_read_binary_limits_and_pinned_connection() { -+ for (size, bound, replace) in [ -+ (0, 10, false), -+ (10, 10, false), -+ (11, 10, false), -+ (FILE_READ_CHUNK_SIZE, FILE_READ_CHUNK_SIZE, false), -+ (FILE_READ_CHUNK_SIZE + 37, FILE_READ_CHUNK_SIZE + 100, true), -+ (FILE_READ_CHUNK_SIZE + 37, FILE_READ_CHUNK_SIZE + 36, false), -+ ] { -+ let data: Vec = (0..size).map(|index| (index % 256) as u8).collect(); -+ let (client, mut peer) = connected().await; -+ let owner = client.clone(); -+ let read = tokio::spawn(async move { owner.read_file_bounded(&path(), bound).await }); -+ let opened = peer.request(FS_OPEN_METHOD).await; -+ let params: FsOpenParams = -+ serde_json::from_value(opened.params.expect("open params")).expect("open type"); -+ assert_eq!(params.path, path()); -+ assert!(params.sandbox.is_none()); -+ let (replacement, mut replacement_peer) = connection(); -+ let (replacement, _events) = RpcClient::new(replacement); -+ if replace { -+ client -+ .inner -+ .connection -+ .lock() -+ .expect("connection lock") -+ .status = ConnectionStatus::Connected(Arc::new(replacement)); -+ } -+ peer.respond(opened.id, json!({"handleId":params.handle_id})) -+ .await; -+ let mut offset = 0; -+ loop { -+ let request = peer.request(FS_READ_BLOCK_METHOD).await; -+ let block: FsReadBlockParams = -+ serde_json::from_value(request.params.expect("block params")).expect("block type"); -+ assert_eq!(block.handle_id, params.handle_id); -+ assert_eq!(block.offset, offset as u64); -+ assert_eq!(block.len, FILE_READ_CHUNK_SIZE.min(bound + 1 - offset)); -+ let end = size.min(offset + block.len); -+ let eof = end - offset < block.len; -+ peer.respond( -+ request.id, -+ serde_json::to_value(FsReadBlockResponse { -+ chunk: data[offset..end].to_vec().into(), -+ eof, -+ }) -+ .expect("block response"), -+ ) -+ .await; -+ offset = end; -+ if eof || offset == bound + 1 { -+ break; -+ } -+ } -+ let closed = peer.request(FS_CLOSE_METHOD).await; -+ assert_eq!(closed.params, Some(json!({"handleId":params.handle_id}))); -+ assert!( -+ !read.is_finished(), -+ "read must retain ownership until close acknowledgement" -+ ); -+ peer.respond(closed.id, json!({})).await; -+ let result = read.await.expect("read task").expect("bounded bytes"); -+ assert_eq!(result.bytes, data[..size.min(bound)]); -+ assert_eq!(result.truncated, size > bound); -+ assert!( -+ replacement_peer.requests.try_recv().is_err(), -+ "read or close moved to replacement" -+ ); -+ assert!(peer.requests.try_recv().is_err()); -+ } -+} -+ -+#[tokio::test] -+async fn bounded_read_errors_require_ordered_close_and_preserve_uncertainty() { -+ for fault in [ -+ "open_server", -+ "open_json", -+ "open_handle", -+ "read_server", -+ "read_json", -+ "read_oversize", -+ "read_empty", -+ "close_server", -+ "close_json", -+ "close_disconnect", -+ "read_disconnect", -+ ] { -+ let (client, mut peer) = connected().await; -+ let read = tokio::spawn(async move { client.read_file_bounded(&path(), 8).await }); -+ let opened = peer.request(FS_OPEN_METHOD).await; -+ let params: FsOpenParams = -+ serde_json::from_value(opened.params.expect("open params")).expect("open type"); -+ if fault == "open_server" { -+ peer.reject(opened.id).await; -+ } else if fault == "open_json" { -+ peer.respond(opened.id, json!({})).await; -+ } else if fault == "open_handle" { -+ peer.respond(opened.id, json!({"handleId":"wrong"})).await; -+ } else { -+ peer.respond(opened.id, json!({"handleId":params.handle_id})) -+ .await; -+ let block = peer.request(FS_READ_BLOCK_METHOD).await; -+ match fault { -+ "read_server" => peer.reject(block.id).await, -+ "read_json" => peer.respond(block.id, json!({})).await, -+ "read_disconnect" => { -+ peer.responses -+ .send(JsonRpcConnectionEvent::Disconnected { reason: None }) -+ .await -+ .expect("disconnect"); -+ } -+ _ => { -+ let (bytes, eof) = match fault { -+ "read_oversize" => (vec![0; 10], true), -+ "read_empty" => (vec![], false), -+ _ => (vec![0, 255, 128], true), -+ }; -+ peer.respond( -+ block.id, -+ serde_json::to_value(FsReadBlockResponse { -+ chunk: bytes.into(), -+ eof, -+ }) -+ .expect("block response"), -+ ) -+ .await; -+ } -+ } -+ } -+ if fault != "open_server" && fault != "read_disconnect" { -+ let closed = peer.request(FS_CLOSE_METHOD).await; -+ assert_eq!(closed.params, Some(json!({"handleId":params.handle_id}))); -+ assert!(!read.is_finished()); -+ match fault { -+ "close_server" => peer.reject(closed.id).await, -+ "close_json" => peer.respond(closed.id, json!(null)).await, -+ "close_disconnect" => { -+ peer.responses -+ .send(JsonRpcConnectionEvent::Disconnected { reason: None }) -+ .await -+ .expect("disconnect"); -+ } -+ _ => peer.respond(closed.id, json!({})).await, -+ } -+ } -+ let failure = timeout(Duration::from_secs(2), read) -+ .await -+ .expect("read deadline") -+ .expect("read task") -+ .expect_err("read fails"); -+ let settled = matches!(fault, "open_server" | "read_server"); -+ assert_eq!(failure.unsettled, !settled, "{fault}"); -+ if matches!(fault, "open_server" | "read_server") { -+ assert_eq!(failure.error.kind(), io::ErrorKind::NotFound); -+ } -+ assert!( -+ peer.requests.try_recv().is_err(), -+ "extra request after {fault}" -+ ); -+ } -+} -+ -+#[tokio::test] -+async fn bounded_read_invalid_bound_sends_no_requests() { -+ let (client, mut peer) = connected().await; -+ for bound in [0, MAX_BOUNDED_FILE_READ_BYTES + 1, usize::MAX] { -+ let error = client -+ .read_file_bounded(&path(), bound) -+ .await -+ .expect_err("invalid bound"); -+ assert_eq!(error.error.kind(), io::ErrorKind::InvalidInput); -+ assert!(!error.unsettled); -+ assert!(peer.requests.try_recv().is_err()); -+ } -+} -diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs -index bd758cd..a87df97 100644 ---- a/codex-rs/exec-server/src/client.rs -+++ b/codex-rs/exec-server/src/client.rs -@@ -1,3 +1,10 @@ -+#[path = "bounded_file_read.rs"] -+mod bounded_file_read; -+ -+pub use bounded_file_read::BoundedFileRead; -+pub use bounded_file_read::BoundedFileReadError; -+pub use bounded_file_read::MAX_BOUNDED_FILE_READ_BYTES; -+ - use std::collections::BTreeMap; - use std::collections::HashMap; - use std::sync::Arc; -diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs -index 953f8d4..9362a0a 100644 ---- a/codex-rs/exec-server/src/environment.rs -+++ b/codex-rs/exec-server/src/environment.rs -@@ -796,6 +796,25 @@ impl Environment { - } - } - -+ /// Reads bounded remote bytes on one current connection and awaits its close acknowledgement. -+ pub async fn read_file_bounded( -+ &self, -+ path: &codex_utils_path_uri::PathUri, -+ max_bytes: usize, -+ ) -> Result { -+ let client = self -+ .remote_client -+ .as_ref() -+ .ok_or_else(|| crate::BoundedFileReadError { -+ error: std::io::Error::new( -+ std::io::ErrorKind::Unsupported, -+ "bounded reads require a remote environment", -+ ), -+ unsettled: false, -+ })?; -+ client.read_file_bounded(path, max_bytes).await -+ } -+ - pub fn is_remote(&self) -> bool { - self.remote_client.is_some() - } -diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs -index f0e2303..c0b20ba 100644 ---- a/codex-rs/exec-server/src/lib.rs -+++ b/codex-rs/exec-server/src/lib.rs -@@ -54,8 +54,11 @@ pub use arg0_exec_helper::main as run_arg0_exec_helper_main; - pub use capability_discovery::CapabilityDiscoveryError; - pub use capability_discovery::discover_capability_roots; - pub use capability_discovery_cache::ExecutorCapabilityDiscoveryCache; -+pub use client::BoundedFileRead; -+pub use client::BoundedFileReadError; - pub use client::ExecServerClient; - pub use client::ExecServerError; -+pub use client::MAX_BOUNDED_FILE_READ_BYTES; - pub use client::http_client::HttpResponseBodyStream; - pub use client::http_client::RouteAwareHttpClient; - pub use client_api::ExecServerClientConnectOptions; -diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs -index d574cec..28ad6ba 100644 ---- a/codex-rs/exec-server/src/remote_file_system.rs -+++ b/codex-rs/exec-server/src/remote_file_system.rs -@@ -415,7 +415,7 @@ fn remote_sandbox_context( - .map(FileSystemSandboxContext::drop_cwd_if_unused) - } - --fn map_remote_error(error: ExecServerError) -> io::Error { -+pub(crate) fn map_remote_error(error: ExecServerError) -> io::Error { - match error { - ExecServerError::Server { code, message } if code == NOT_FOUND_ERROR_CODE => { - io::Error::new(io::ErrorKind::NotFound, message) diff --git a/packages/codex-harness/patches/manager-exposure.patch b/packages/codex-harness/patches/manager-exposure.patch deleted file mode 100644 index 8f1dcb765..000000000 --- a/packages/codex-harness/patches/manager-exposure.patch +++ /dev/null @@ -1,91 +0,0 @@ -diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs -index 0b4fe17..cdf2280 100644 ---- a/codex-rs/app-server/src/lib.rs -+++ b/codex-rs/app-server/src/lib.rs -@@ -467,6 +467,68 @@ pub async fn run_main_with_transport_options( - session_source: SessionSource, - auth: AppServerWebsocketAuthSettings, - runtime_options: AppServerRuntimeOptions, -+) -> IoResult<()> { -+ run_main_with_transport_options_inner( -+ arg0_paths, -+ cli_config_overrides, -+ loader_overrides, -+ strict_config, -+ default_analytics_enabled, -+ transport, -+ session_source, -+ auth, -+ runtime_options, -+ None, -+ ) -+ .await -+} -+ -+/// Runs the stock raw transport and publishes its shared environment manager. -+/// -+/// The handle is published after manager construction, before transport startup. -+/// It does not signal successful startup, initialization, or environment readiness. -+/// The caller must supervise this future and release its handle when the runner -+/// stops. A receiver dropped before publication fails startup with `BrokenPipe`. -+#[allow(clippy::too_many_arguments)] -+pub async fn run_main_with_transport_options_and_environment_manager( -+ arg0_paths: Arg0DispatchPaths, -+ cli_config_overrides: CliConfigOverrides, -+ loader_overrides: LoaderOverrides, -+ strict_config: bool, -+ default_analytics_enabled: bool, -+ transport: AppServerTransport, -+ session_source: SessionSource, -+ auth: AppServerWebsocketAuthSettings, -+ runtime_options: AppServerRuntimeOptions, -+ environment_manager_tx: tokio::sync::oneshot::Sender>, -+) -> IoResult<()> { -+ run_main_with_transport_options_inner( -+ arg0_paths, -+ cli_config_overrides, -+ loader_overrides, -+ strict_config, -+ default_analytics_enabled, -+ transport, -+ session_source, -+ auth, -+ runtime_options, -+ Some(environment_manager_tx), -+ ) -+ .await -+} -+ -+#[allow(clippy::too_many_arguments)] -+async fn run_main_with_transport_options_inner( -+ arg0_paths: Arg0DispatchPaths, -+ cli_config_overrides: CliConfigOverrides, -+ loader_overrides: LoaderOverrides, -+ strict_config: bool, -+ default_analytics_enabled: bool, -+ transport: AppServerTransport, -+ session_source: SessionSource, -+ auth: AppServerWebsocketAuthSettings, -+ runtime_options: AppServerRuntimeOptions, -+ environment_manager_tx: Option>>, - ) -> IoResult<()> { - let loader_overrides = loader_overrides_with_test_user_config_file( - loader_overrides, -@@ -583,6 +645,17 @@ pub async fn run_main_with_transport_options( - .map(Arc::new) - .map_err(std::io::Error::other)?; - -+ if let Some(environment_manager_tx) = environment_manager_tx { -+ environment_manager_tx -+ .send(Arc::clone(&environment_manager)) -+ .map_err(|_| { -+ std::io::Error::new( -+ ErrorKind::BrokenPipe, -+ "environment manager receiver dropped before publication", -+ ) -+ })?; -+ } -+ - let otel = codex_core::otel_init::build_provider( - &config, - env!("CARGO_PKG_VERSION"), diff --git a/packages/codex-harness/prepare.py b/packages/codex-harness/prepare.py deleted file mode 100644 index aeb5bd8a9..000000000 --- a/packages/codex-harness/prepare.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -"""Export and prepare the pinned private harness build without resolving dependencies.""" - -import argparse -import hashlib -import json -import subprocess -from pathlib import Path - - -def sha(data): - return hashlib.sha256(data).hexdigest() - - -def private_path(value): - path = Path(value).expanduser() - if not path.is_absolute(): - raise ValueError("harness paths must be absolute") - path = path.resolve() - root = (Path.home() / ".parsar").resolve() - if path == root or not path.is_relative_to(root): - raise ValueError("harness state must be below ~/.parsar") - return path - - -def checked_bytes(path, expected): - data = path.read_bytes() - if sha(data) != expected: - raise ValueError("source identity differs: " + str(path)) - return data - - -def load_manifest(): - package = Path(__file__).resolve().parent - raw = (package / "source.json").read_bytes() - manifest = json.loads(raw) - for key in ("patch", "bounded_read_patch", "build_overlay"): - checked_bytes(package / manifest[key]["file"], manifest[key]["sha256"]) - sources = sorted((package / manifest["source_directory"]).rglob("*.rs")) - if not sources or not (package / manifest["source_directory"] / "main.rs").is_file(): - raise ValueError("harness Rust sources are missing") - return package, raw, manifest, sources - - -def normalize_lock(original, overlay, version): - if sha(original) != overlay["original_sha256"]: - raise ValueError("unexpected upstream Cargo.lock") - parts = original.split(b"[[package]]") - changed = 0 - for index, part in enumerate(parts[1:], 1): - if b"\nsource = " not in part and b'\nversion = "0.0.0"\n' in part: - parts[index] = part.replace( - b'\nversion = "0.0.0"\n', ('\nversion = "' + version + '"\n').encode(), 1 - ) - changed += 1 - normalized = b"[[package]]".join(parts) - if changed != overlay["workspace_packages"] or sha(normalized) != overlay["normalized_sha256"]: - raise ValueError("workspace-only lock normalization differs") - return normalized - - -def prepare(source, output): - package, raw, manifest, sources = load_manifest() - source = Path(source).expanduser() - if not source.is_absolute(): - raise ValueError("native Git source must be absolute") - output = private_path(output) - revision = manifest["revision"] - resolved = subprocess.check_output( - ["git", "-C", str(source), "rev-parse", revision + "^{commit}"], text=True - ).strip() - if resolved != revision: - raise ValueError("native source revision differs") - output.mkdir(parents=True, exist_ok=False) - with subprocess.Popen( - ["git", "-C", str(source), "archive", "--format=tar", revision], stdout=subprocess.PIPE - ) as archive: - try: - subprocess.run(["tar", "-xf", "-", "-C", str(output)], stdin=archive.stdout, check=True) - finally: - archive.stdout.close() - if archive.wait() != 0: - raise RuntimeError("native source export failed") - lock = output / "codex-rs/Cargo.lock" - lock.write_bytes(normalize_lock(lock.read_bytes(), manifest["cargo_lock"], manifest["native_version"])) - cargo_manifest = output / "codex-rs/app-server/Cargo.toml" - checked_bytes(cargo_manifest, manifest["build_overlay"]["original_manifest_sha256"]) - for key in ("patch", "bounded_read_patch", "build_overlay"): - patch = package / manifest[key]["file"] - subprocess.run(["git", "apply", "--check", str(patch)], cwd=output, check=True) - subprocess.run(["git", "apply", str(patch)], cwd=output, check=True) - checked_bytes(cargo_manifest, manifest["build_overlay"]["prepared_manifest_sha256"]) - source_hashes = {} - for source_file in sources: - relative = source_file.relative_to(package / manifest["source_directory"]) - data = source_file.read_bytes() - target = output / manifest["target_directory"] / relative - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) - source_hashes[str(relative)] = sha(data) - record = { - "revision": revision, - "manifest_sha256": sha(raw), - "patch_sha256": manifest["patch"]["sha256"], - "bounded_read_patch_sha256": manifest["bounded_read_patch"]["sha256"], - "build_overlay_sha256": manifest["build_overlay"]["sha256"], - "prepared_manifest_sha256": sha(cargo_manifest.read_bytes()), - "prepared_lock_sha256": sha(lock.read_bytes()), - "rust_toolchain": manifest["rust_toolchain"], - "sources": source_hashes, - } - (output / "preparation.json").write_text(json.dumps(record, indent=2) + "\n") - return output - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--source", type=Path, help="existing upstream Git checkout") - parser.add_argument("--output", type=Path, help="new export below ~/.parsar") - parser.add_argument("--check", action="store_true", help="verify local manifest and patch identities") - parser.add_argument("--check-path", action="append", default=[], help="verify an isolated build path") - args = parser.parse_args() - for value in args.check_path: - private_path(value) - if args.check: - load_manifest() - elif args.source is not None and args.output is not None: - print(prepare(args.source, args.output)) - elif not args.check_path: - parser.error("provide --source and --output, or --check") - - -if __name__ == "__main__": - main() diff --git a/packages/codex-harness/prepare_test.py b/packages/codex-harness/prepare_test.py deleted file mode 100644 index 9931df4b1..000000000 --- a/packages/codex-harness/prepare_test.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 - -import json -import tempfile -import unittest -from pathlib import Path - -from prepare import checked_bytes, normalize_lock, private_path, sha - - -class PreparationTests(unittest.TestCase): - def test_only_workspace_versions_change(self): - original = ( - b'[[package]]\nname = "native"\nversion = "0.0.0"\n' - b'[[package]]\nname = "external"\nversion = "0.0.0"\nsource = "registry+example"\n' - ) - expected = original.replace(b'version = "0.0.0"', b'version = "0.153.4"', 1) - overlay = { - "original_sha256": sha(original), - "normalized_sha256": sha(expected), - "workspace_packages": 1, - } - self.assertEqual(normalize_lock(original, overlay, "0.153.4"), expected) - for field, value in (("original_sha256", "wrong"), ("normalized_sha256", "wrong"), ("workspace_packages", 2)): - with self.subTest(field=field), self.assertRaises(ValueError): - normalize_lock(original, {**overlay, field: value}, "0.153.4") - - def test_private_build_paths_reject_escape(self): - root = Path.home() / ".parsar" - root.mkdir(exist_ok=True) - with tempfile.TemporaryDirectory(prefix="harness-path-test-", dir=root) as directory: - base = Path(directory) - self.assertEqual(private_path(base / "output"), base.resolve() / "output") - (base / "escape").symlink_to(root.parent, target_is_directory=True) - for value in ("relative", root, root / ".." / "outside", base / "escape" / "outside"): - with self.subTest(value=value), self.assertRaises(ValueError): - private_path(value) - - def test_bounded_read_patch_has_separate_identity_and_native_scope(self): - package = Path(__file__).resolve().parent - manifest = json.loads((package / "source.json").read_text()) - patch = manifest["bounded_read_patch"] - data = checked_bytes(package / patch["file"], patch["sha256"]) - with self.assertRaises(ValueError): - checked_bytes(package / patch["file"], manifest["patch"]["sha256"]) - paths = [line.split()[2][2:] for line in data.decode().splitlines() if line.startswith("diff --git ")] - self.assertEqual(set(paths), { - "codex-rs/exec-server/src/bounded_file_read.rs", - "codex-rs/exec-server/src/bounded_file_read_tests.rs", - "codex-rs/exec-server/src/client.rs", - "codex-rs/exec-server/src/environment.rs", - "codex-rs/exec-server/src/lib.rs", - "codex-rs/exec-server/src/remote_file_system.rs", - }) - - def test_shared_patch_identity_and_fixture_references(self): - package = Path(__file__).resolve().parent - root = package.parents[1] - manifest = json.loads((package / "source.json").read_text()) - canonical = package / manifest["patch"]["file"] - checked_bytes(canonical, manifest["patch"]["sha256"]) - with self.assertRaises(ValueError): - checked_bytes(canonical, "wrong") - for name in ("raw_manager", "raw_files", "retirement"): - fixture = root / "services/agents-api/tests/native" / name / "source.json" - reference = json.loads(fixture.read_text())["patch"] - self.assertEqual((fixture.parent / reference["file"]).resolve(), canonical.resolve()) - self.assertEqual(reference["sha256"], manifest["patch"]["sha256"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/codex-harness/source.json b/packages/codex-harness/source.json deleted file mode 100644 index 52d8bc6ce..000000000 --- a/packages/codex-harness/source.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "repository": "https://github.com/openai/codex", - "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", - "native_version": "0.153.4", - "rust_toolchain": "1.95.0", - "scope": "opt-in private Linux amd64 harness artifact with remote metadata and bounded reads; no public admission", - "patch": { - "file": "patches/manager-exposure.patch", - "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" - }, - "cargo_lock": { - "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", - "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", - "workspace_packages": 149 - }, - "build_overlay": { - "file": "patches/artifact-target.patch", - "sha256": "18606942555a060f4b626fd1ab5c0e7e6324f118dd13a65f28f546d03c622f1a", - "original_manifest_sha256": "687db2b91d42c568dddb09adc5958eff6998008c733af5186e8a6a340ccfa301", - "prepared_manifest_sha256": "63113ae56325bea3f42ab2f8b59983ec2a533f6914248ece9238459d97462b53" - }, - "binary": "parsar-codex-harness", - "source_directory": "src", - "target_directory": "codex-rs/app-server/parsar-harness", - "bounded_read_patch": { - "file": "patches/bounded-read.patch", - "sha256": "5a6a49ac0b9b9398b772bc27c6256eb11af64487427bc57715d7800178c2e5dc" - } -} diff --git a/packages/codex-harness/src/files.rs b/packages/codex-harness/src/files.rs deleted file mode 100644 index 538884921..000000000 --- a/packages/codex-harness/src/files.rs +++ /dev/null @@ -1,426 +0,0 @@ -use anyhow::{Context, Result, ensure}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD; -use codex_exec_server::{ - Environment, EnvironmentManager, EnvironmentObservedStatus, GetMetadataOptions, - MAX_BOUNDED_FILE_READ_BYTES, -}; -use codex_utils_path_uri::PathUri; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::future::Future; -use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; -use std::path::{Component, Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::oneshot; -use tokio::time::{Instant, timeout_at}; -use tokio_util::sync::CancellationToken; - -use crate::options::Binding; - -const MAX_FRAME: usize = 8192; -const REQUEST_DEADLINE: Duration = Duration::from_secs(10); - -#[path = "files_directory.rs"] -mod directory; -#[path = "files_process_output.rs"] -mod process_output; -#[path = "files_write.rs"] -mod write; - -pub struct PrivateSocket { - listener: UnixListener, - root: PathBuf, - uid: u32, -} - -impl PrivateSocket { - pub fn bind(root: &Path) -> Result { - let uid = std::fs::metadata("/proc/self")?.uid(); - let state = PathBuf::from(std::env::var_os("HOME").context("HOME is required")?) - .join(".parsar") - .canonicalize()?; - let parent = root.parent().context("IPC parent is missing")?; - let canonical = parent.canonicalize()?; - ensure!( - canonical == parent && parent.starts_with(&state), - "IPC root must be below canonical ~/.parsar" - ); - for ancestor in parent.ancestors() { - let metadata = std::fs::symlink_metadata(ancestor)?; - ensure!( - metadata.is_dir() - && (metadata.uid() == uid || metadata.uid() == 0) - && metadata.mode() & 0o022 == 0, - "IPC ancestors must be trusted directories" - ); - } - ensure!( - root.join("files.sock").as_os_str().len() < 104, - "IPC socket path is too long" - ); - std::fs::DirBuilder::new() - .mode(0o700) - .create(root) - .context("IPC root must be new")?; - let listener = match UnixListener::bind(root.join("files.sock")) { - Ok(listener) => listener, - Err(error) => { - let _ = std::fs::remove_dir(root); - return Err(error).context("bind private metadata socket"); - } - }; - let socket = Self { - listener, - root: root.to_owned(), - uid, - }; - std::fs::set_permissions( - socket.root.join("files.sock"), - std::fs::Permissions::from_mode(0o600), - )?; - Ok(socket) - } - - pub async fn serve( - &self, - published: oneshot::Receiver>, - binding: &Binding, - stopping: &CancellationToken, - ) -> Result<()> { - let manager = tokio::select! { - biased; - _ = stopping.cancelled() => return Ok(()), - result = published => result.context("native manager was not published")?, - }; - loop { - // A native deadline ends this owner: dropping a response future - // does not settle the remote operation or authorize another one. - let (stream, _) = tokio::select! { - biased; - _ = stopping.cancelled() => return Ok(()), - result = self.listener.accept() => result?, - }; - if stream.peer_cred()?.uid() != self.uid { - continue; - } - if let Ok(outcome) = serve_connection(stream, &manager, binding, stopping).await { - outcome.require_settled()?; - } - } - } -} - -impl Drop for PrivateSocket { - fn drop(&mut self) { - // Remove only this instance's known socket and empty private directory. - let _ = std::fs::remove_file(self.root.join("files.sock")); - let _ = std::fs::remove_dir(&self.root); - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Request { - environment_id: String, - path: String, - #[serde(default)] - operation: Operation, - max_bytes: Option, - max_entries: Option, - size_bytes: Option, -} - -#[derive(Default, Deserialize)] -#[serde(rename_all = "snake_case")] -enum Operation { - #[default] - Metadata, - Read, - ListDirectory, - Write, -} - -struct Command { - write: Option, - path: PathUri, - read_limit: Option, - directory: Option<(PathUri, usize, Option)>, -} - -fn request_command(frame: &[u8], binding: &Binding) -> Result { - if frame.len() > MAX_FRAME || !frame.ends_with(b"\n") { - return Err("invalid_request"); - } - let request: Request = serde_json::from_slice(frame).map_err(|_| "invalid_request")?; - if request.environment_id != binding.environment { - return Err("wrong_environment"); - } - let (read_limit, directory_limit, write_size) = match ( - request.operation, - request.max_bytes, - request.max_entries, - request.size_bytes, - ) { - (Operation::Metadata, None, None, None) => (None, None, None), - (Operation::Read, Some(limit), None, None) - if (1..=MAX_BOUNDED_FILE_READ_BYTES).contains(&limit) => - { - (Some(limit), None, None) - } - (Operation::ListDirectory, None, Some(limit), None) - if (1..=directory::MAX_ENTRIES).contains(&limit) => - { - (None, Some(limit), None) - } - (Operation::Write, None, None, Some(size)) if size <= write::MAX_BYTES => { - (None, None, Some(size)) - } - _ => return Err("invalid_request"), - }; - let path = Path::new(&request.path); - if (request.path.is_empty() && directory_limit.is_none()) - || request.path.contains(['\0', '\\']) - || ((directory_limit.is_some() || write_size.is_some()) - && (request.path.contains(['\r', '\n']) - || (!request.path.is_empty() - && request - .path - .split('/') - .any(|part| part.is_empty() || part == "." || part == "..")))) - || !path - .components() - .all(|part| matches!(part, Component::Normal(_))) - { - return Err("invalid_path"); - } - Ok(Command { - write: write_size - .map(|size| { - binding - .write - .clone() - .map(|write_binding| write::Upload { - binding: write_binding, - workspace: binding.workspace.clone(), - relative: request.path.clone(), - size, - bytes: Vec::new(), - }) - .ok_or("unsupported") - }) - .transpose()?, - path: PathUri::from_host_native_path(binding.workspace.join(path)) - .map_err(|_| "invalid_path")?, - read_limit, - directory: directory_limit - .map(|limit| { - PathUri::from_host_native_path(binding.workspace.clone()) - .map(|workspace| (workspace, limit, binding.directory_helper.clone())) - .map_err(|_| "invalid_path") - }) - .transpose()?, - }) -} - -async fn ready_environment(manager: &EnvironmentManager) -> Result, &'static str> { - // The native manager key is distinct from the registry's Environment UUID; - // startup validates that UUID against the frozen operator binding. - let environment = manager - .get_environment("remote") - .ok_or("environment_unavailable")?; - if manager.try_local_environment().is_some() - || !environment.is_remote() - || !matches!(environment.status().await, EnvironmentObservedStatus::Ready) - { - return Err("environment_unavailable"); - } - Ok(environment) -} - -fn file_error(error: std::io::Error) -> &'static str { - match error.kind() { - std::io::ErrorKind::NotFound => "not_found", - std::io::ErrorKind::PermissionDenied => "permission_denied", - _ => "native_error", - } -} - -async fn execute(manager: &EnvironmentManager, command: Command) -> Result { - let environment = ready_environment(manager) - .await - .map_err(OperationError::Rejected)?; - if let Some(upload) = command.write { - return write::install(&environment, upload).await; - } - if let Some((workspace, limit, helper)) = command.directory { - return directory::list( - &environment, - &workspace, - &command.path, - limit, - helper.as_deref(), - ) - .await; - } - if let Some(limit) = command.read_limit { - let read = environment - .read_file_bounded(&command.path, limit) - .await - .map_err(|failure| { - if failure.unsettled { - OperationError::Unsettled - } else { - OperationError::Rejected(file_error(failure.error)) - } - })?; - return Ok(json!({"read": { - "data_base64": STANDARD.encode(read.bytes), - "truncated": read.truncated, - "close_acknowledged": true, - }})); - } - // This private operator endpoint does not establish public path isolation. - // Native parent-component traversal remains subject to deployment policy. - let metadata = environment - .get_filesystem() - .get_metadata( - &command.path, - GetMetadataOptions { - follow_symlinks: false, - }, - None, - ) - .await - .map_err(|error| OperationError::Rejected(file_error(error)))?; - Ok( - json!({"metadata":{"size":metadata.size,"is_file":metadata.is_file,"is_directory":metadata.is_directory,"is_symlink":metadata.is_symlink,"created_at_ms":metadata.created_at_ms,"modified_at_ms":metadata.modified_at_ms}}), - ) -} - -#[derive(Debug)] -enum OperationError { - Rejected(&'static str), - Unsettled, -} - -#[derive(Debug, PartialEq)] -enum ConnectionOutcome { - Settled, - UnsettledNativeOperation, -} - -impl ConnectionOutcome { - fn require_settled(self) -> Result<()> { - ensure!( - self == Self::Settled, - "native file deadline expired or settlement unconfirmed; owner stopped with remote operation unresolved" - ); - Ok(()) - } -} - -async fn serve_connection( - stream: UnixStream, - manager: &EnvironmentManager, - binding: &Binding, - stopping: &CancellationToken, -) -> Result { - exchange(stream, binding, REQUEST_DEADLINE, stopping, |command| { - execute(manager, command) - }) - .await -} - -async fn exchange( - stream: UnixStream, - binding: &Binding, - duration: Duration, - stopping: &CancellationToken, - operation: F, -) -> Result -where - F: FnOnce(Command) -> R, - R: Future>, -{ - let mut deadline = Instant::now() + duration; - let (read, mut write) = stream.into_split(); - let mut reader = BufReader::new(read); - let mut frame = Vec::new(); - let frame_result = { - let mut header = (&mut reader).take((MAX_FRAME + 1) as u64); - tokio::select! { - biased; - _ = stopping.cancelled() => return Ok(ConnectionOutcome::Settled), - result = timeout_at(deadline, header.read_until(b'\n', &mut frame)) => result, - } - }; - match frame_result { - Ok(result) => { - result?; - } - Err(_) => return Ok(ConnectionOutcome::Settled), - } - // From admission until the native response/deadline, only this owner holds - // the operation. Caller disconnect and runner shutdown do not drop its wait. - let result = match request_command(&frame, binding) { - Ok(mut command) => { - if let Some(upload) = &mut command.write { - // Writes include bounded transfer; reads keep their original deadline. - deadline += duration * 5; - upload.bytes.resize(upload.size, 0); - let received = tokio::select! { - biased; - _ = stopping.cancelled() => return Ok(ConnectionOutcome::Settled), - result = timeout_at(deadline, reader.read_exact(&mut upload.bytes)) => result, - }; - if !matches!(received, Ok(Ok(_))) { - // No native process exists before the complete bounded body. - return Ok(ConnectionOutcome::Settled); - } - } - match timeout_at(deadline, operation(command)).await { - Ok(result) => result, - Err(_) => return Ok(ConnectionOutcome::UnsettledNativeOperation), - } - } - Err(error) => Err(OperationError::Rejected(error)), - }; - let response = match result { - Ok(value) => value, - Err(OperationError::Rejected(error)) => json!({"error":error}), - Err(OperationError::Unsettled) => return Ok(ConnectionOutcome::UnsettledNativeOperation), - }; - let mut bytes = serde_json::to_vec(&response)?; - bytes.push(b'\n'); - // The native future returned. Response delivery no longer owns that wait; - // a transport error still cannot establish remote operation retirement. - tokio::select! { - biased; - _ = stopping.cancelled() => {}, - _ = timeout_at(deadline, async { - write.write_all(&bytes).await?; - write.shutdown().await - }) => {}, - } - Ok(ConnectionOutcome::Settled) -} - -#[cfg(test)] -#[path = "files_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "files_read_tests.rs"] -mod read_tests; - -#[cfg(test)] -#[path = "files_directory_tests.rs"] -mod directory_tests; - -#[cfg(test)] -#[path = "files_write_tests.rs"] -mod write_tests; diff --git a/packages/codex-harness/src/files_directory.rs b/packages/codex-harness/src/files_directory.rs deleted file mode 100644 index b8f0ff33e..000000000 --- a/packages/codex-harness/src/files_directory.rs +++ /dev/null @@ -1,156 +0,0 @@ -use super::{OperationError, process_output::Output}; -use codex_exec_server::{ - Environment, ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, -}; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::{ - FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, - FileSystemSpecialPath, NetworkSandboxPolicy, -}; -use codex_sandboxing::SandboxType; -use codex_utils_path_uri::PathUri; -use serde_json::Value; -use std::collections::HashMap; -use std::path::{Component, Path}; -use uuid::Uuid; - -#[path = "files_directory_output.rs"] -mod output; - -pub(super) const MAX_ENTRIES: usize = 4096; - -pub(super) async fn list( - environment: &Environment, - workspace: &PathUri, - path: &PathUri, - limit: usize, - helper: Option<&Path>, -) -> Result { - let invalid = || OperationError::Rejected("unsupported"); - let root = workspace.to_abs_path().map_err(|_| invalid())?; - let target = path.to_abs_path().map_err(|_| invalid())?; - let helper = helper - .filter(|helper| qualified_path(helper, root.as_path())) - .ok_or_else(invalid)?; - let relative = target - .as_path() - .strip_prefix(root.as_path()) - .ok() - .and_then(Path::to_str) - .ok_or_else(invalid)?; - let policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: workspace.clone(), - }, - FileSystemAccessMode::Read, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: PathUri::from_host_native_path(helper).map_err(|_| invalid())?, - }, - FileSystemAccessMode::Read, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - FileSystemAccessMode::Read, - ), - ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( - PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), - workspace.clone(), - ); - let started = environment - .get_exec_backend() - .start(ExecParams { - process_id: ProcessId::from(format!("directory-{}", Uuid::new_v4())), - argv: vec![ - helper.to_string_lossy().into_owned(), - root.to_string_lossy().into_owned(), - relative.into(), - limit.to_string(), - ], - cwd: workspace.clone(), - shell_snapshot: None, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: false, - arg0: None, - sandbox: Some(sandbox), - enforce_managed_network: false, - managed_network: None, - network_proxy: None, - }) - .await - .map_err(|_| OperationError::Unsettled)?; - let process = started.process.as_ref(); - if !matches!(started.sandbox_type, Some(SandboxType::LinuxSeccomp)) { - return stop_and_reject(process).await; - } - let mut output = Output::default(); - loop { - // Uncapped snapshots expose the native global cursor. Capped reads can - // report closed while omitting chunks, and retained history can evict data. - let response = process - .read(Some(output.after()), None, Some(1000)) - .await - .map_err(|_| OperationError::Unsettled)?; - if response.failure.is_some() { - return Err(OperationError::Unsettled); - } - let settled = response.closed && response.exited; - if output.append(&response).is_err() { - return if settled { - Err(OperationError::Rejected("native_error")) - } else { - stop_and_reject(process).await - }; - } - if settled { - if response.exit_code != Some(0) || response.sandbox_denied { - return Err(OperationError::Rejected("native_error")); - } - return output::decode(&output.bytes, limit); - } - } -} - -fn qualified_path(helper: &Path, workspace: &Path) -> bool { - let Some(value) = helper.to_str() else { - return false; - }; - helper.is_absolute() - && !helper.starts_with(workspace) - && value - .split('/') - .skip(1) - .all(|part| !part.is_empty() && part != "." && part != "..") - && !value.contains(['\\', '\0', '\r', '\n']) - && helper - .components() - .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) -} - -async fn stop_and_reject(process: &dyn ExecProcess) -> Result { - process - .terminate() - .await - .map_err(|_| OperationError::Unsettled)?; - loop { - let response = process - .read(None, None, Some(1000)) - .await - .map_err(|_| OperationError::Unsettled)?; - if response.failure.is_some() { - return Err(OperationError::Unsettled); - } - // A terminate reply alone is not cleanup. The enclosing retained wait - // bounds this drain and fails the owner if exit/output close stay unknown. - if response.exited && response.closed { - return Err(OperationError::Rejected("native_error")); - } - } -} diff --git a/packages/codex-harness/src/files_directory_output.rs b/packages/codex-harness/src/files_directory_output.rs deleted file mode 100644 index 8faa76608..000000000 --- a/packages/codex-harness/src/files_directory_output.rs +++ /dev/null @@ -1,70 +0,0 @@ -use super::OperationError; -use serde::Deserialize; -use serde_json::{Value, json}; - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Envelope { - version: u32, - directory: Option, - error: Option, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Directory { - entries: Vec, - truncated: bool, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Entry { - name: String, - kind: String, - size_bytes: Option, -} - -pub(super) fn decode(bytes: &[u8], limit: usize) -> Result { - let invalid = || OperationError::Rejected("native_error"); - let envelope: Envelope = serde_json::from_slice(bytes).map_err(|_| invalid())?; - if envelope.version != 1 { - return Err(invalid()); - } - match (envelope.directory, envelope.error) { - (None, Some(error)) => Err(OperationError::Rejected(match error.as_str() { - "not_found" => "not_found", - "permission_denied" => "permission_denied", - "invalid_path" => "invalid_path", - "too_large" => "too_large", - _ => "native_error", - })), - (Some(directory), None) if directory.entries.len() <= limit => { - let mut seen = std::collections::HashSet::new(); - let mut entries = Vec::with_capacity(directory.entries.len()); - for entry in directory.entries { - if entry.name.is_empty() - || entry.name.len() > 255 - || entry.name == "." - || entry.name == ".." - || entry.name.contains(['/', '\\', '\0', '\r', '\n']) - || !seen.insert(entry.name.clone()) - { - return Err(invalid()); - } - match (entry.kind.as_str(), entry.size_bytes) { - ("file", Some(size)) if size >= 0 => {} - ("directory" | "symlink" | "other", None) => {} - _ => return Err(invalid()), - } - entries.push( - json!({"name":entry.name,"kind":entry.kind,"size_bytes":entry.size_bytes}), - ); - } - Ok(json!({"directory":{"entries":entries,"truncated":directory.truncated}})) - } - _ => Err(invalid()), - } -} - -#[cfg(test)] -#[path = "files_directory_output_tests.rs"] -mod tests; diff --git a/packages/codex-harness/src/files_directory_output_tests.rs b/packages/codex-harness/src/files_directory_output_tests.rs deleted file mode 100644 index fd7b02867..000000000 --- a/packages/codex-harness/src/files_directory_output_tests.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::super::super::process_output::Output; -use super::*; -use codex_exec_server::{ExecOutputStream, ProcessOutputChunk, ReadResponse}; - -fn response(next_seq: u64, chunks: &[(u64, &[u8])], exited: bool, closed: bool) -> ReadResponse { - ReadResponse { - chunks: chunks - .iter() - .map(|(seq, bytes)| ProcessOutputChunk { - seq: *seq, - stream: ExecOutputStream::Stdout, - chunk: bytes.to_vec().into(), - }) - .collect(), - next_seq, - exited, - exit_code: exited.then_some(0), - closed, - failure: None, - sandbox_denied: false, - } -} - -#[test] -fn complete_snapshots_account_for_exit_before_late_output() { - let mut output = Output::default(); - output - .append(&response(2, &[(1, b"first")], false, false)) - .expect("first snapshot"); - // Exit consumes sequence 2; output can still arrive as sequence 3. - output - .append(&response(4, &[(3, b"last")], true, false)) - .expect("late output"); - output - .append(&response(5, &[], true, true)) - .expect("closed"); - assert_eq!(output.bytes, b"firstlast"); - assert_eq!(output.after(), 4); -} - -#[test] -fn missing_retained_output_is_rejected_even_if_tail_is_valid_json() { - let valid = br#"{"version":1,"directory":{"entries":[],"truncated":false}}"#; - assert!( - Output::default() - .append(&response(6, &[(3, valid)], true, true)) - .is_err() - ); - // Closed on a capped snapshot cannot hide missing output chunks. - assert!( - Output::default() - .append(&response(5, &[(1, valid)], true, true)) - .is_err() - ); - assert!( - Output::default() - .append(&response(5, &[(1, b"a"), (1, b"b")], true, true)) - .is_err() - ); -} - -#[test] -fn output_is_bounded_and_rejects_mixed_streams_and_regression() { - let mut output = Output::default(); - let bytes = vec![b'x'; 1024 * 1024]; - for sequence in 1..=4 { - output - .append(&response(sequence + 1, &[(sequence, &bytes)], false, false)) - .expect("bounded"); - } - assert!( - output - .append(&response(6, &[(5, b"x")], false, false)) - .is_err() - ); - let mut mixed = response(4, &[(1, b"x")], true, true); - mixed.chunks[0].stream = ExecOutputStream::Stderr; - assert!(Output::default().append(&mixed).is_err()); - let mut output = Output::default(); - output.append(&response(2, &[], true, false)).expect("exit"); - assert!(output.append(&response(2, &[], false, false)).is_err()); -} - -#[test] -fn only_versioned_complete_bounded_directory_envelopes_are_accepted() { - let valid = br#"{"version":1,"directory":{"entries":[{"name":"a","kind":"file","size_bytes":0}],"truncated":false}}"#; - assert!(decode(valid, 1).is_ok()); - assert!(decode(valid, 0).is_err()); - for bytes in [ - br#"{"version":2,"directory":{"entries":[],"truncated":false}}"#.as_slice(), - br#"{"version":1,"directory":{"entries":[]}}"#, - br#"{"version":1,"directory":{"entries":[],"truncated":false},"error":"not_found"}"#, - br#"{"version":1,"directory":{"entries":[{"name":"../a","kind":"file","size_bytes":0}],"truncated":false}}"#, - br#"{"version":1,"directory":{"entries":[{"name":"a","kind":"symlink","size_bytes":1}],"truncated":false}}"#, - br#"{"version":1,"directory":{"entries":[],"truncated":false}}{}"#, - ] { assert!(decode(bytes, 1).is_err(), "{bytes:?}"); } -} - -#[test] -fn helper_selector_cannot_be_relative_or_inside_the_workspace() { - use std::path::Path; - for helper in [ - "relative", - "/workspace/helper", - "/workspace/nested/helper", - "/trusted/../helper", - "/trusted//helper", - "/trusted/helper/", - ] { - assert!(!super::super::qualified_path( - Path::new(helper), - Path::new("/workspace") - )); - } - assert!(super::super::qualified_path( - Path::new("/usr/local/bin/helper"), - Path::new("/workspace") - )); -} diff --git a/packages/codex-harness/src/files_directory_tests.rs b/packages/codex-harness/src/files_directory_tests.rs deleted file mode 100644 index 249106a03..000000000 --- a/packages/codex-harness/src/files_directory_tests.rs +++ /dev/null @@ -1,88 +0,0 @@ -use super::*; - -#[test] -fn directory_root_and_limits_are_operation_specific() { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - for path in ["", "nested/path"] { - let request = json!({"environment_id":"expected", "operation":"list_directory", "path":path, "max_entries":2}); - let command = request_command(format!("{request}\n").as_bytes(), &binding) - .expect("directory request"); - let (root, limit, _) = command.directory.expect("directory operation"); - assert_eq!( - root.to_abs_path().expect("absolute root").as_path(), - Path::new("/workspace") - ); - assert_eq!(limit, 2); - assert!(command.read_limit.is_none()); - } - for path in ["/outside", ".", "a/../b", "a//b", "a/", "a\r", "a\n"] { - let request = json!({"environment_id":"expected", "operation":"list_directory", "path":path, "max_entries":2}); - assert!(request_command(format!("{request}\n").as_bytes(), &binding).is_err()); - } - for fields in [ - json!({"max_entries":null}), - json!({"max_entries":0}), - json!({"max_entries":directory::MAX_ENTRIES+1}), - json!({"max_entries":2,"max_bytes":1}), - ] { - let mut request = - json!({"environment_id":"expected", "operation":"list_directory", "path":""}); - request - .as_object_mut() - .expect("object") - .extend(fields.as_object().expect("fields").clone()); - assert!(request_command(format!("{request}\n").as_bytes(), &binding).is_err()); - } - assert!( - request_command( - b"{\"environment_id\":\"expected\",\"path\":\"\"}\n", - &binding - ) - .is_err() - ); -} - -#[tokio::test] -async fn directory_transport_loss_fences_the_owner_after_dispatch() -> Result<()> { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - for kind in [ - std::io::ErrorKind::BrokenPipe, - std::io::ErrorKind::ConnectionReset, - std::io::ErrorKind::TimedOut, - std::io::ErrorKind::Other, - ] { - let (server, mut client) = UnixStream::pair()?; - client.write_all(b"{\"environment_id\":\"expected\",\"operation\":\"list_directory\",\"path\":\"\",\"max_entries\":1}\n").await?; - let stopping = CancellationToken::new(); - let outcome = exchange( - server, - &binding, - REQUEST_DEADLINE, - &stopping, - |command| async move { - assert!(command.directory.is_some()); - let _ = kind; - Err(OperationError::Unsettled) - }, - ) - .await?; - assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); - assert!(outcome.require_settled().is_err()); - assert_eq!(client.read(&mut [0; 1]).await?, 0); - } - Ok(()) -} diff --git a/packages/codex-harness/src/files_process_output.rs b/packages/codex-harness/src/files_process_output.rs deleted file mode 100644 index 1ebe88249..000000000 --- a/packages/codex-harness/src/files_process_output.rs +++ /dev/null @@ -1,51 +0,0 @@ -use codex_exec_server::{ExecOutputStream, ReadResponse}; - -const MAX_OUTPUT: usize = 4 * 1024 * 1024; - -#[derive(Default)] -pub(super) struct Output { - pub bytes: Vec, - cursor: u64, - exited: bool, - closed: bool, -} - -impl Output { - pub fn after(&self) -> u64 { - self.cursor - } - - pub fn append(&mut self, response: &ReadResponse) -> Result<(), ()> { - let next = response.next_seq.checked_sub(1).ok_or(())?; - if next < self.cursor - || (self.exited && !response.exited) - || (self.closed && !response.closed) - || response.exited != response.exit_code.is_some() - || (response.closed && !response.exited) - { - return Err(()); - } - let events = response.chunks.len() as u64 - + u64::from(response.exited && !self.exited) - + u64::from(response.closed && !self.closed); - if next - self.cursor != events { - return Err(()); - } - let mut previous = self.cursor; - for chunk in &response.chunks { - if chunk.seq <= previous - || chunk.seq > next - || chunk.stream != ExecOutputStream::Stdout - || chunk.chunk.0.len() > MAX_OUTPUT.saturating_sub(self.bytes.len()) - { - return Err(()); - } - previous = chunk.seq; - self.bytes.extend_from_slice(&chunk.chunk.0); - } - self.cursor = next; - self.exited = response.exited; - self.closed = response.closed; - Ok(()) - } -} diff --git a/packages/codex-harness/src/files_read_tests.rs b/packages/codex-harness/src/files_read_tests.rs deleted file mode 100644 index 7285f710e..000000000 --- a/packages/codex-harness/src/files_read_tests.rs +++ /dev/null @@ -1,101 +0,0 @@ -use super::*; - -fn binding() -> Binding { - Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - } -} - -#[test] -fn read_requires_an_explicit_bounded_limit() { - let binding = binding(); - for fields in [ - json!({"operation":"read"}), - json!({"operation":"read","max_bytes":null}), - json!({"operation":"read","max_bytes":0}), - json!({"operation":"read","max_bytes":-1}), - json!({"operation":"read","max_bytes":MAX_BOUNDED_FILE_READ_BYTES + 1}), - json!({"operation":"read","max_bytes":1.5}), - json!({"operation":"write","max_bytes":1}), - json!({"max_bytes":1}), - ] { - let mut frame = json!({"environment_id":"expected","path":"file"}); - frame - .as_object_mut() - .expect("object") - .extend(fields.as_object().expect("fields").clone()); - assert!(request_command(format!("{frame}\n").as_bytes(), &binding).is_err()); - } - for limit in [1, MAX_BOUNDED_FILE_READ_BYTES] { - let frame = - json!({"environment_id":"expected","path":"file","operation":"read","max_bytes":limit}); - let command = - request_command(format!("{frame}\n").as_bytes(), &binding).expect("valid read"); - assert_eq!(command.read_limit, Some(limit)); - } -} - -#[tokio::test] -async fn read_wait_includes_close_after_caller_detaches() -> Result<()> { - let binding = binding(); - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - client.write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\",\"operation\":\"read\",\"max_bytes\":4}\n").await?; - let (admitted, admission) = oneshot::channel(); - let (read_done, read_result) = oneshot::channel(); - let (closing, close_started) = oneshot::channel(); - let (close_done, close_result) = oneshot::channel(); - let files = exchange( - server, - &binding, - REQUEST_DEADLINE, - &stopping, - |command| async move { - assert_eq!(command.read_limit, Some(4)); - admitted.send(()).expect("admission observed"); - read_result.await.expect("read wait retained"); - closing.send(()).expect("close observed"); - close_result.await.expect("close wait retained") - }, - ); - tokio::pin!(files); - tokio::select! { - result = &mut files => panic!("read ended before admission: {result:?}"), - result = admission => result?, - } - drop(client); - stopping.cancel(); - read_done.send(()).expect("read wait survives detach"); - tokio::select! { - result = &mut files => panic!("read ended before close: {result:?}"), - result = close_started => result?, - } - assert!(futures::poll!(&mut files).is_pending()); - close_done - .send(Err(OperationError::Unsettled)) - .expect("close wait survives stop"); - let outcome = files.await?; - assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); - assert!(outcome.require_settled().is_err()); - Ok(()) -} - -#[tokio::test] -async fn unconfirmed_read_or_close_never_delivers_a_success_response() -> Result<()> { - let binding = binding(); - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - client.write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\",\"operation\":\"read\",\"max_bytes\":1}\n").await?; - let outcome = exchange(server, &binding, REQUEST_DEADLINE, &stopping, |_| async { - Err(OperationError::Unsettled) - }) - .await?; - assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); - assert_eq!(client.read(&mut [0; 1]).await?, 0); - Ok(()) -} diff --git a/packages/codex-harness/src/files_tests.rs b/packages/codex-harness/src/files_tests.rs deleted file mode 100644 index c4cdd8319..000000000 --- a/packages/codex-harness/src/files_tests.rs +++ /dev/null @@ -1,298 +0,0 @@ -use super::*; - -#[tokio::test] -async fn runner_completion_keeps_the_original_admitted_deadline() -> Result<()> { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - client - .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") - .await?; - // Establish actual socket readiness before using the controlled clock. - server.readable().await?; - tokio::time::pause(); - let (admitted, mut admission) = oneshot::channel(); - let (complete, completion) = oneshot::channel(); - let (finish_runner, runner_done) = oneshot::channel(); - let files = async { - exchange( - server, - &binding, - REQUEST_DEADLINE, - &stopping, - |_path| async { - admitted.send(()).expect("observe admission"); - completion.await.expect("retain native response") - }, - ) - .await? - .require_settled() - }; - let operation = crate::owner::supervise( - async { runner_done.await.map_err(Into::into) }, - files, - &stopping, - ); - tokio::pin!(operation); - let started = Instant::now(); - assert!(futures::poll!(&mut operation).is_pending()); - admission.try_recv()?; - tokio::time::advance(Duration::from_secs(6)).await; - finish_runner.send(()).expect("runner still owned"); - assert!(futures::poll!(&mut operation).is_pending()); - assert!(stopping.is_cancelled()); - assert!(!complete.is_closed()); - tokio::time::advance(Duration::from_millis(3999)).await; - assert!(futures::poll!(&mut operation).is_pending()); - tokio::time::advance(Duration::from_millis(1)).await; - let error = operation - .await - .expect_err("original deadline must fail the owner"); - // Tokio's timer wheel may round the original deadline up by one millisecond. - assert!( - (REQUEST_DEADLINE..=REQUEST_DEADLINE + Duration::from_millis(1)) - .contains(&(Instant::now() - started)) - ); - assert_eq!(error.to_string(), "private file operation did not drain"); - assert!(error.root_cause().to_string().contains("deadline expired")); - assert!(complete.send(Ok(json!({"size": 42}))).is_err()); - Ok(()) -} - -#[tokio::test] -async fn admitted_operation_survives_caller_detach_and_owner_stop() -> Result<()> { - for stop_owner in [false, true] { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - client - .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") - .await?; - let (admitted, admission) = oneshot::channel(); - let (complete, completion) = oneshot::channel(); - let operation = exchange( - server, - &binding, - Duration::from_secs(1), - &stopping, - |_path| async { - admitted.send(()).expect("observe admission"); - completion.await.expect("owned native response") - }, - ); - tokio::pin!(operation); - // Poll actual admission before dropping the caller, without a sleep. - tokio::select! { - result = &mut operation => panic!("operation ended before native reply: {result:?}"), - result = admission => result?, - } - drop(client); - if stop_owner { - stopping.cancel(); - } - assert!(futures::poll!(&mut operation).is_pending()); - complete - .send(Ok(json!({"size": 42}))) - .expect("caller detach must retain native wait"); - operation.await?.require_settled()?; - } - Ok(()) -} - -#[tokio::test] -async fn stopped_owner_does_not_admit_even_a_complete_frame() -> Result<()> { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - let stopping = CancellationToken::new(); - stopping.cancel(); - let (server, mut client) = UnixStream::pair()?; - client - .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") - .await?; - exchange( - server, - &binding, - REQUEST_DEADLINE, - &stopping, - |_path| async { panic!("stopped owner must not admit native work") }, - ) - .await? - .require_settled()?; - drop(client); - Ok(()) -} - -#[tokio::test] -async fn private_socket_collision_never_removes_the_original() -> Result<()> { - let state = PathBuf::from(std::env::var_os("HOME").context("HOME missing")?).join(".parsar"); - let parent = tempfile::Builder::new().prefix("hm-").tempdir_in(state)?; - std::fs::set_permissions(parent.path(), std::fs::Permissions::from_mode(0o700))?; - let root = parent.path().join("owner"); - let socket = PrivateSocket::bind(&root)?; - assert_eq!(std::fs::metadata(&root)?.mode() & 0o777, 0o700); - assert_eq!( - std::fs::metadata(root.join("files.sock"))?.mode() & 0o777, - 0o600 - ); - assert!(PrivateSocket::bind(&root).is_err()); - let client = UnixStream::connect(root.join("files.sock")).await?; - let (server, _) = socket.listener.accept().await?; - assert_eq!(server.peer_cred()?.uid(), socket.uid); - drop((client, server, socket)); - assert!(!root.exists()); - Ok(()) -} - -#[tokio::test] -async fn invalid_requests_and_local_manager_never_reach_host_metadata() -> Result<()> { - let manager = EnvironmentManager::default_for_tests(); - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/".into(), - ipc_root: "/unused".into(), - }; - for (request, expected) in [ - ( - b"{\"environment_id\":\"expected\",\"path\":\"etc/passwd\"}\n".to_vec(), - "environment_unavailable", - ), - ( - b"{\"environment_id\":\"expected\",\"path\":\"etc/passwd\",\"operation\":\"read\",\"max_bytes\":1}\n".to_vec(), - "environment_unavailable", - ), - ( - b"{\"environment_id\":\"wrong\",\"path\":\"etc/passwd\"}\n".to_vec(), - "wrong_environment", - ), - (vec![b'x'; MAX_FRAME + 1], "invalid_request"), - ] { - let (server, mut client) = UnixStream::pair()?; - let exchange = async { - client.write_all(&request).await?; - let mut response = Vec::new(); - client.read_to_end(&mut response).await?; - anyhow::Ok(serde_json::from_slice::(&response)?) - }; - let stopping = CancellationToken::new(); - let (_, response) = tokio::try_join!( - serve_connection(server, &manager, &binding, &stopping), - exchange - )?; - assert_eq!(response, json!({"error":expected})); - } - let (server, mut client) = UnixStream::pair()?; - assert!( - tokio::time::timeout( - Duration::from_millis(20), - serve_connection(server, &manager, &binding, &CancellationToken::new()) - ) - .await - .is_err() - ); - assert_eq!(client.read(&mut [0; 1]).await?, 0); - Ok(()) -} - -#[tokio::test] -async fn pending_native_response_requires_owner_failure() -> Result<()> { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - let (server, mut client) = UnixStream::pair()?; - client - .write_all(b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n") - .await?; - let (dispatched, observed) = oneshot::channel(); - let (response, received) = oneshot::channel(); - let outcome = exchange( - server, - &binding, - Duration::from_millis(20), - &CancellationToken::new(), - |_path| async { - dispatched.send(()).unwrap(); - // The remote side has accepted work but has not settled its reply. - received.await.unwrap() - }, - ) - .await?; - observed.await?; - assert_eq!(outcome, ConnectionOutcome::UnsettledNativeOperation); - assert!(outcome.require_settled().is_err()); - assert_eq!(client.read(&mut [0; 1]).await?, 0); - // A response producer can still complete after its receiver was dropped; - // this is why timeout must fail the owner rather than release admission. - assert!(response.send(Ok(json!({"size": 1}))).is_err()); - - let (server, mut client) = UnixStream::pair()?; - let outcome = exchange( - server, - &binding, - Duration::from_millis(20), - &CancellationToken::new(), - |_path| async { panic!("a stalled frame must not dispatch native work") }, - ) - .await?; - outcome.require_settled()?; - assert_eq!(client.read(&mut [0; 1]).await?, 0); - Ok(()) -} - -#[test] -fn rejects_wrong_identity_and_nonrelative_paths() { - let binding = Binding { - write: None, - directory_helper: Some("/usr/local/bin/agents-api-codex-directory".into()), - native_binary: "/native".into(), - environment: "expected".into(), - workspace: "/workspace".into(), - ipc_root: "/unused".into(), - }; - for path in ["", "/etc/passwd", "../file", "a/../file", "a\\b"] { - let frame = format!("{}\n", json!({"environment_id":"expected","path":path})); - assert!(request_command(frame.as_bytes(), &binding).is_err()); - } - assert!( - request_command( - b"{\"environment_id\":\"wrong\",\"path\":\"file\"}\n", - &binding - ) - .is_err() - ); - assert!( - request_command( - b"{\"environment_id\":\"expected\",\"path\":\"file\"}\n", - &binding - ) - .is_ok() - ); - assert!(request_command(&vec![b'x'; MAX_FRAME + 1], &binding).is_err()); -} diff --git a/packages/codex-harness/src/files_write.rs b/packages/codex-harness/src/files_write.rs deleted file mode 100644 index 3542879cd..000000000 --- a/packages/codex-harness/src/files_write.rs +++ /dev/null @@ -1,160 +0,0 @@ -use super::{OperationError, process_output::Output}; -use crate::options::WriteBinding; -use codex_exec_server::{ - Environment, ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, WriteStatus, -}; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::{ - FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, - FileSystemSpecialPath, NetworkSandboxPolicy, -}; -use codex_sandboxing::SandboxType; -use codex_utils_path_uri::PathUri; -use serde::Deserialize; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::path::PathBuf; -use uuid::Uuid; - -pub(super) const MAX_BYTES: usize = 50 * 1024 * 1024; -const CHUNK_BYTES: usize = 64 * 1024; - -pub(super) struct Upload { - pub binding: WriteBinding, - pub workspace: PathBuf, - pub relative: String, - pub size: usize, - pub bytes: Vec, -} - -pub(super) async fn install( - environment: &Environment, - upload: Upload, -) -> Result { - let invalid = |_| OperationError::Rejected("unsupported"); - let workspace = PathUri::from_host_native_path(&upload.workspace).map_err(invalid)?; - let policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: PathUri::from_host_native_path(&upload.binding.parent).map_err(invalid)?, - }, - FileSystemAccessMode::Write, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: PathUri::from_host_native_path(&upload.binding.helper).map_err(invalid)?, - }, - FileSystemAccessMode::Read, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - FileSystemAccessMode::Read, - ), - ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( - PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), - workspace.clone(), - ); - let started = environment - .get_exec_backend() - .start(ExecParams { - process_id: ProcessId::from(format!("file-write-{}", Uuid::new_v4())), - argv: vec![ - upload.binding.helper.to_string_lossy().into_owned(), - upload.workspace.to_string_lossy().into_owned(), - upload.relative, - upload.size.to_string(), - upload.binding.staging.to_string_lossy().into_owned(), - ], - cwd: workspace, - shell_snapshot: None, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: true, - arg0: None, - sandbox: Some(sandbox), - enforce_managed_network: false, - managed_network: None, - network_proxy: None, - }) - .await - .map_err(|_| OperationError::Unsettled)?; - let process = started.process.as_ref(); - if !matches!(started.sandbox_type, Some(SandboxType::LinuxSeccomp)) { - return stop_unknown(process).await; - } - transfer(process, &upload.bytes).await -} - -async fn transfer(process: &dyn ExecProcess, bytes: &[u8]) -> Result { - let digest = Sha256::digest(bytes); - for chunk in bytes - .chunks(CHUNK_BYTES) - .chain(std::iter::once(digest.as_slice())) - { - // Native write owns its request identity. Never recapture a connection or - // retry a chunk here; Accepted only acknowledges queued stdin. - match process.write(chunk.to_vec()).await { - Ok(response) if matches!(response.status, WriteStatus::Accepted) => {} - _ => return stop_unknown(process).await, - } - } - let mut output = Output::default(); - loop { - let response = process - .read(Some(output.after()), None, Some(1000)) - .await - .map_err(|_| OperationError::Unsettled)?; - if response.failure.is_some() { - return Err(OperationError::Unsettled); - } - if output.append(&response).is_err() { - return stop_unknown(process).await; - } - if response.closed && response.exited { - if response.exit_code != Some(0) || response.sandbox_denied { - return Err(OperationError::Unsettled); - } - return decode(&output.bytes, bytes.len()); - } - } -} - -async fn stop_unknown(process: &dyn ExecProcess) -> Result { - // Best-effort termination cannot establish whether replacement committed. - // The existing retained owner deadline bounds this attempt and any wait. - let _ = process.terminate().await; - Err(OperationError::Unsettled) -} - -#[derive(Deserialize)] -#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] -enum Receipt { - Completed { version: u32, size_bytes: usize }, - Failed { version: u32, error: String }, -} - -fn decode(bytes: &[u8], expected: usize) -> Result { - match serde_json::from_slice::(bytes) { - Ok(Receipt::Completed { - version: 1, - size_bytes, - }) if size_bytes == expected => { - Ok(json!({"write":{"size_bytes":size_bytes,"committed":true}})) - } - Ok(Receipt::Failed { version: 1, error }) - if matches!(error.as_str(), "invalid_input" | "write_failed") => - { - Err(OperationError::Rejected("native_error")) - } - _ => Err(OperationError::Unsettled), - } -} - -#[cfg(test)] -#[path = "files_write_process_tests.rs"] -mod tests; diff --git a/packages/codex-harness/src/files_write_process_tests.rs b/packages/codex-harness/src/files_write_process_tests.rs deleted file mode 100644 index b0a25f7af..000000000 --- a/packages/codex-harness/src/files_write_process_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::*; -use codex_exec_server::{ - ExecOutputStream, ExecProcessEventReceiver, ExecProcessFuture, ProcessOutputChunk, - ProcessSignal, ReadResponse, WriteResponse, -}; -use std::sync::{ - Mutex, - atomic::{AtomicBool, Ordering}, -}; -use tokio::sync::watch; - -struct Process { - id: ProcessId, - chunks: Mutex>>, - reject: bool, - receipt: Vec, - terminated: AtomicBool, -} -impl Process { - fn new(receipt: &[u8]) -> Self { - Self { - id: ProcessId::from("test"), - chunks: Mutex::new(Vec::new()), - reject: false, - receipt: receipt.into(), - terminated: AtomicBool::new(false), - } - } -} -impl ExecProcess for Process { - fn process_id(&self) -> &ProcessId { - &self.id - } - fn subscribe_wake(&self) -> watch::Receiver { - watch::channel(0).1 - } - fn subscribe_events(&self) -> ExecProcessEventReceiver { - ExecProcessEventReceiver::empty() - } - fn read( - &self, - after: Option, - max: Option, - _: Option, - ) -> ExecProcessFuture<'_, ReadResponse> { - assert_eq!(after, Some(0)); - assert_eq!(max, None); - Box::pin(async { - Ok(ReadResponse { - chunks: vec![ProcessOutputChunk { - seq: 1, - stream: ExecOutputStream::Stdout, - chunk: self.receipt.clone().into(), - }], - next_seq: 4, - exited: true, - exit_code: Some(0), - closed: true, - failure: None, - sandbox_denied: false, - }) - }) - } - fn write(&self, chunk: Vec) -> ExecProcessFuture<'_, WriteResponse> { - self.chunks.lock().unwrap().push(chunk); - Box::pin(async { - Ok(WriteResponse { - status: if self.reject { - WriteStatus::StdinClosed - } else { - WriteStatus::Accepted - }, - }) - }) - } - fn signal(&self, _: ProcessSignal) -> ExecProcessFuture<'_, ()> { - panic!("unused") - } - fn terminate(&self) -> ExecProcessFuture<'_, ()> { - self.terminated.store(true, Ordering::SeqCst); - Box::pin(async { Ok(()) }) - } -} - -#[tokio::test] -async fn exact_binary_chunks_and_empty_body_require_a_commit_receipt() { - for size in [0, 1, CHUNK_BYTES * 2 + 17] { - let bytes: Vec<_> = (0..size).map(|n| (n % 256) as u8).collect(); - let process = Process::new( - format!("{{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":{size}}}").as_bytes(), - ); - assert_eq!( - transfer(&process, &bytes).await.unwrap()["write"]["size_bytes"], - size - ); - let chunks = process.chunks.lock().unwrap(); - assert!(chunks.iter().all(|chunk| chunk.len() <= CHUNK_BYTES)); - assert_eq!(chunks[..chunks.len() - 1].concat(), bytes); - assert_eq!( - chunks.last().unwrap().as_slice(), - Sha256::digest(&bytes).as_slice() - ); - assert!(!process.terminated.load(Ordering::SeqCst)); - } - let process = Process::new(b""); - assert!(matches!( - transfer(&process, b"data").await, - Err(OperationError::Unsettled) - )); -} - -#[tokio::test] -async fn rejected_stdin_stops_without_replaying_or_reporting_commit() { - let mut process = Process::new(b""); - process.reject = true; - assert!(matches!( - transfer(&process, &vec![0; CHUNK_BYTES + 1]).await, - Err(OperationError::Unsettled) - )); - assert_eq!(process.chunks.lock().unwrap().len(), 1); - assert!(process.terminated.load(Ordering::SeqCst)); -} - -#[test] -fn only_complete_versioned_exact_receipts_resolve_the_write() { - assert!(matches!( - decode( - br#"{"version":1,"outcome":"failed","error":"write_failed"}"#, - 3 - ), - Err(OperationError::Rejected("native_error")) - )); - for value in [ - json!({"version":1,"outcome":"completed","size_bytes":2}), - json!({"version":2,"outcome":"completed","size_bytes":3}), - json!({"version":1,"outcome":"completed","size_bytes":3,"error":null}), - json!({"version":1,"outcome":"unknown","error":"write_failed"}), - json!({"version":1,"outcome":"failed","error":"unknown"}), - ] { - assert!(matches!( - decode(&serde_json::to_vec(&value).unwrap(), 3), - Err(OperationError::Unsettled) - )); - } - for bytes in [ - b"{}".as_slice(), - b"{", - br#"{"version":1,"outcome":"completed","size_bytes":3}{}"#, - ] { - assert!(matches!(decode(bytes, 3), Err(OperationError::Unsettled))); - } -} diff --git a/packages/codex-harness/src/files_write_tests.rs b/packages/codex-harness/src/files_write_tests.rs deleted file mode 100644 index afac7ddd8..000000000 --- a/packages/codex-harness/src/files_write_tests.rs +++ /dev/null @@ -1,156 +0,0 @@ -use super::*; -use crate::options::WriteBinding; - -fn binding() -> Binding { - let workspace = PathBuf::from("/data/workspace"); - Binding { - write: WriteBinding::from_paths( - &workspace, - Some("/bin/helper".into()), - Some("/data/staging".into()), - ) - .unwrap(), - directory_helper: None, - native_binary: "/native".into(), - environment: "expected".into(), - workspace, - ipc_root: "/unused".into(), - } -} -fn frame(path: &str, size: usize) -> Vec { - format!( - "{}\n", - json!({"environment_id":"expected","path":path,"operation":"write","size_bytes":size}) - ) - .into_bytes() -} - -#[test] -fn write_is_opt_in_and_never_admitted_by_read_only_preparation() { - let mut binding = binding(); - for size in [0, write::MAX_BYTES] { - assert!(request_command(&frame("a/b", size), &binding).is_ok()); - } - assert!(request_command(&frame("file", write::MAX_BYTES + 1), &binding).is_err()); - for path in ["", "/file", "a//b", "a/./b", "../b", "a/", "a\nb"] { - assert!(request_command(&frame(path, 0), &binding).is_err()); - } - for extra in [ - json!({"max_bytes":1}), - json!({"max_entries":1}), - json!({"size_bytes":null}), - json!({"size_bytes":-1}), - ] { - let mut value: Value = serde_json::from_slice(&frame("a", 0)).unwrap(); - value - .as_object_mut() - .unwrap() - .extend(extra.as_object().unwrap().clone()); - assert!(request_command(format!("{value}\n").as_bytes(), &binding).is_err()); - } - binding.restrict_reads(true); - assert!(matches!( - request_command(&frame("file", 0), &binding), - Err("unsupported") - )); -} - -#[tokio::test] -async fn body_must_be_complete_before_any_native_dispatch() -> Result<()> { - let binding = binding(); - for (declared, payload) in [ - (4, b"abc".as_slice()), - (write::MAX_BYTES + 1, b"".as_slice()), - ] { - let (server, mut client) = UnixStream::pair()?; - client.write_all(&frame("file", declared)).await?; - client.write_all(payload).await?; - client.shutdown().await?; - assert_eq!( - exchange( - server, - &binding, - REQUEST_DEADLINE, - &CancellationToken::new(), - |_| async { panic!("incomplete input reached native execution") } - ) - .await?, - ConnectionOutcome::Settled - ); - } - Ok(()) -} - -#[tokio::test] -async fn coalesced_header_and_binary_body_are_preserved_and_detach_retains_wait() -> Result<()> { - for size in [0, 256 * 1024 + 3] { - let binding = binding(); - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - let bytes: Vec<_> = (0..size).map(|n| (n % 256) as u8).collect(); - let mut input = frame("binary", size); - input.extend(&bytes); - let send = tokio::spawn(async move { - client.write_all(&input).await?; - Ok::<_, std::io::Error>(client) - }); - let (admitted, admission) = oneshot::channel(); - let (complete, completion) = oneshot::channel(); - let operation = exchange( - server, - &binding, - REQUEST_DEADLINE, - &stopping, - |command| async move { - let upload = command.write.expect("write"); - assert_eq!(upload.bytes, bytes); - admitted.send(()).unwrap(); - completion.await.unwrap() - }, - ); - tokio::pin!(operation); - tokio::select! { result = &mut operation => panic!("premature: {result:?}"), result = admission => result? } - drop(send.await??); - stopping.cancel(); - assert!(futures::poll!(&mut operation).is_pending()); - complete.send(Err(OperationError::Unsettled)).unwrap(); - assert_eq!( - operation.await?, - ConnectionOutcome::UnsettledNativeOperation - ); - } - Ok(()) -} - -#[tokio::test] -async fn body_shutdown_is_safe_but_native_deadline_is_unresolved() -> Result<()> { - for admitted in [false, true] { - let binding = binding(); - let stopping = CancellationToken::new(); - let (server, mut client) = UnixStream::pair()?; - client - .write_all(&frame("pending", if admitted { 0 } else { 1 })) - .await?; - server.readable().await?; - let (started, start) = oneshot::channel(); - let duration = Duration::from_millis(10); - let operation = exchange(server, &binding, duration, &stopping, |_| async move { - started.send(()).unwrap(); - std::future::pending::>().await - }); - tokio::pin!(operation); - if admitted { - tokio::select! { result = &mut operation => panic!("premature: {result:?}"), result = start => result? } - stopping.cancel(); - assert_eq!( - operation.await?, - ConnectionOutcome::UnsettledNativeOperation - ); - } else { - assert!(futures::poll!(&mut operation).is_pending()); - stopping.cancel(); - assert_eq!(operation.await?, ConnectionOutcome::Settled); - } - } - Ok(()) -} diff --git a/packages/codex-harness/src/main.rs b/packages/codex-harness/src/main.rs deleted file mode 100644 index 8ffc34d9a..000000000 --- a/packages/codex-harness/src/main.rs +++ /dev/null @@ -1,211 +0,0 @@ -mod files; -mod options; -mod owner; -mod read_profile; - -use anyhow::{Context, Result}; -use clap::Parser; -use codex_app_server::{ - AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, - PluginStartupTasks, RemoteControlStartupMode, - run_main_with_transport_options_and_environment_manager, -}; -use codex_arg0::{Arg0DispatchPaths, Arg0PathEntryGuard, arg0_dispatch}; -use codex_config::LoaderOverrides; -use codex_protocol::protocol::SessionSource; -use std::future::Future; -use std::time::Duration; -use tokio::sync::oneshot; -use tokio_util::sync::CancellationToken; - -fn main() -> Result<()> { - let (binding, _native_paths) = prepare_native(); - let cli = options::Cli::parse(); - run_owned_runtime(run_harness(cli, binding?)) -} - -fn prepare_native() -> (Result, Option) { - // Freeze operator selectors before native dotenv loading can change the - // environment. Native helper dispatch and CLI help may exit without them. - let binding = options::Binding::from_environment(); - let paths = arg0_dispatch(); - (binding, paths) -} - -fn run_owned_runtime(operation: impl Future>) -> Result<()> { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let result = runtime.block_on(operation); - // Native stdin uses an uncancellable blocking read. Bound local teardown so - // the caller observes process exit even while it keeps stdin open. - runtime.shutdown_timeout(Duration::from_secs(1)); - result -} - -async fn run_harness(cli: options::Cli, mut binding: options::Binding) -> Result<()> { - let read_only = cli.workspace_read_only; - binding.restrict_reads(read_only); - let loader = if read_only { - read_profile::loader(&std::path::PathBuf::from( - std::env::var_os("CODEX_HOME").context("read preparation requires CODEX_HOME")?, - ))? - } else { - LoaderOverrides::default() - }; - let overrides = cli.overrides()?; - binding.check_native().await?; - let socket = files::PrivateSocket::bind(&binding.ipc_root)?; - let (publish, published) = oneshot::channel(); - let runner = run_main_with_transport_options_and_environment_manager( - Arg0DispatchPaths { - codex_self_exe: Some(binding.native_binary.clone()), - ..Default::default() - }, - overrides, - loader, - false, - false, - AppServerTransport::Stdio, - SessionSource::VSCode, - AppServerWebsocketAuthSettings::default(), - AppServerRuntimeOptions { - plugin_startup_tasks: if read_only { - PluginStartupTasks::Skip - } else { - PluginStartupTasks::Start - }, - remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, - ..Default::default() - }, - publish, - ); - // The stock single-client runner owns stdin/stdout. No typed event client or - // forwarding queue is inserted between it and the existing Go RPC caller. - let stopping = CancellationToken::new(); - owner::supervise( - async { runner.await.context("native harness stopped") }, - socket.serve(published, &binding, &stopping), - &stopping, - ) - .await - // Process exit is not evidence that remote mutations or descendants retired. -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Read; - use std::process::{Command, Stdio}; - use std::time::Instant; - - #[test] - fn native_bootstrap_loads_credentials_and_freezes_binding() -> Result<()> { - let state = - std::path::PathBuf::from(std::env::var_os("HOME").context("HOME")?).join(".parsar"); - let home = tempfile::Builder::new().prefix("hb-").tempdir_in(state)?; - std::fs::write( - home.path().join(".env"), - "PARSAR_HARNESS_TEST_PROVIDER_KEY=from-native-dotenv\nPARSAR_CODEX_HARNESS_WORKSPACE=/wrong\nCODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=wrong\nPARSAR_CODEX_HARNESS_WRITE_HELPER=/wrong/helper\nPARSAR_CODEX_HARNESS_STAGING=/wrong/staging\n", - )?; - let output = Command::new(std::env::current_exe()?) - .args(["--exact", "tests::native_bootstrap_child", "--nocapture"]) - .env("PARSAR_HARNESS_BOOTSTRAP_TEST", "1") - .env_remove("PARSAR_HARNESS_TEST_PROVIDER_KEY") - .env("CODEX_HOME", home.path()) - .env("PARSAR_CODEX_HARNESS_NATIVE", "/operator/codex") - .env("PARSAR_CODEX_HARNESS_WORKSPACE", "/operator/workspace") - .env("PARSAR_CODEX_HARNESS_WRITE_HELPER", "/trusted/installer") - .env("PARSAR_CODEX_HARNESS_STAGING", "/operator/staging") - .env("PARSAR_CODEX_HARNESS_IPC_ROOT", home.path().join("ipc")) - .env( - "PARSAR_CODEX_HARNESS_ENVIRONMENT", - "11111111-1111-4111-8111-111111111111", - ) - .env( - "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", - "11111111-1111-4111-8111-111111111111", - ) - .output()?; - assert!(output.status.success(), "{output:?}"); - assert!(String::from_utf8_lossy(&output.stdout).contains("native bootstrap verified")); - Ok(()) - } - - #[test] - fn native_bootstrap_child() -> Result<()> { - if std::env::var_os("PARSAR_HARNESS_BOOTSTRAP_TEST").is_none() { - return Ok(()); - } - let (binding, _native_paths) = prepare_native(); - let binding = binding?; - assert_eq!( - std::env::var("PARSAR_HARNESS_TEST_PROVIDER_KEY")?, - "from-native-dotenv" - ); - assert_eq!(std::env::var("PARSAR_CODEX_HARNESS_WORKSPACE")?, "/wrong"); - assert_eq!( - binding.workspace, - std::path::Path::new("/operator/workspace") - ); - assert_eq!( - std::env::var("CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID")?, - binding.environment - ); - let write = binding.write.context("frozen write binding")?; - assert_eq!(write.helper, std::path::Path::new("/trusted/installer")); - assert_eq!(write.staging, std::path::Path::new("/operator/staging")); - println!("native bootstrap verified"); - Ok(()) - } - - #[test] - fn runtime_failure_exits_with_stdin_open() { - let mut child = Command::new(std::env::current_exe().expect("test executable")) - .args(["--exact", "tests::runtime_failure_child", "--nocapture"]) - .env("PARSAR_HARNESS_SHUTDOWN_TEST", "1") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("start shutdown child"); - let held_stdin = child.stdin.take().expect("child stdin"); - let deadline = Instant::now() + Duration::from_secs(10); - loop { - if child.try_wait().expect("poll child").is_some() { - break; - } - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("runtime shutdown waited for open stdin"); - } - std::thread::sleep(Duration::from_millis(20)); - } - let output = child.wait_with_output().expect("read child EOF"); - drop(held_stdin); - assert!(output.status.success(), "{output:?}"); - assert!(String::from_utf8_lossy(&output.stdout).contains("runtime failure returned")); - } - - #[test] - fn runtime_failure_child() { - if std::env::var_os("PARSAR_HARNESS_SHUTDOWN_TEST").is_none() { - return; - } - let error = run_owned_runtime(async { - let (started, wait_started) = oneshot::channel(); - // Exercise the blocking-pool read used by native Tokio stdin, with - // a deterministic admission signal instead of a timing assumption. - tokio::task::spawn_blocking(move || { - started.send(()).expect("signal blocking read"); - let _ = std::io::stdin().read(&mut [0_u8; 1]); - }); - wait_started.await?; - anyhow::bail!("controlled native operation failure") - }) - .expect_err("native failure survives runtime shutdown"); - assert_eq!(error.to_string(), "controlled native operation failure"); - println!("runtime failure returned"); - } -} diff --git a/packages/codex-harness/src/options.rs b/packages/codex-harness/src/options.rs deleted file mode 100644 index 7ef6fec5e..000000000 --- a/packages/codex-harness/src/options.rs +++ /dev/null @@ -1,158 +0,0 @@ -use anyhow::{Context, Result, ensure}; -use clap::{Parser, Subcommand}; -use codex_features::is_known_feature_key; -use codex_utils_cli::CliConfigOverrides; -use std::path::{Component, Path, PathBuf}; -use std::time::Duration; -use uuid::Uuid; - -#[derive(Parser)] -#[command(version, about = "Private exact-pin Parsar Codex harness integration")] -pub struct Cli { - #[arg(long, global = true)] - pub workspace_read_only: bool, - #[command(flatten)] - config: CliConfigOverrides, - #[arg(long, global = true)] - enable: Vec, - #[arg(long, global = true)] - disable: Vec, - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - AppServer { - #[arg(long, required = true)] - stdio: bool, - }, -} - -impl Cli { - pub fn overrides(self) -> Result { - let mut config = self.config; - for (features, enabled) in [(self.enable, true), (self.disable, false)] { - for feature in features { - ensure!(is_known_feature_key(&feature), "unknown native feature"); - config - .raw_overrides - .push(format!("features.{feature}={enabled}")); - } - } - Ok(config) - } -} - -#[path = "options_write.rs"] -mod write; -pub use write::WriteBinding; - -pub struct Binding { - pub write: Option, - pub native_binary: PathBuf, - pub directory_helper: Option, - pub environment: String, - pub workspace: PathBuf, - pub ipc_root: PathBuf, -} - -impl Binding { - pub fn from_environment() -> Result { - fn required(suffix: &str) -> Result { - std::env::var(format!("PARSAR_CODEX_HARNESS_{suffix}")) - .context("explicit private harness configuration is required") - } - let workspace = PathBuf::from(required("WORKSPACE")?); - let binding = Self { - write: WriteBinding::from_environment(&workspace)?, - native_binary: PathBuf::from(required("NATIVE")?), - directory_helper: std::env::var_os("PARSAR_CODEX_HARNESS_DIRECTORY_HELPER") - .filter(|value| !value.is_empty()) - .map(PathBuf::from), - environment: required("ENVIRONMENT")?, - workspace, - ipc_root: PathBuf::from(required("IPC_ROOT")?), - }; - let id = Uuid::parse_str(&binding.environment).context("invalid Environment identity")?; - ensure!( - !id.is_nil() && id.to_string() == binding.environment, - "canonical Environment UUID required" - ); - ensure!( - std::env::var("CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID") - .ok() - .as_deref() - == Some(binding.environment.as_str()), - "native registry Environment must match the operator binding" - ); - ensure!( - clean_absolute(&binding.native_binary), - "native helper requires an absolute path" - ); - ensure!( - clean_absolute(&binding.workspace), - "remote workspace requires an absolute path" - ); - ensure!( - clean_absolute(&binding.ipc_root), - "IPC root requires an absolute path" - ); - Ok(binding) - } - - pub fn restrict_reads(&mut self, read_only: bool) { - if read_only { - self.write = None; - } - } - - pub async fn check_native(&self) -> Result<()> { - let mut command = tokio::process::Command::new(&self.native_binary); - command.arg("--version").kill_on_drop(true); - let output = tokio::time::timeout(Duration::from_secs(5), command.output()) - .await - .context("native version probe timed out")? - .context("native version probe failed")?; - ensure!( - output.status.success() && output.stdout == b"codex-cli 0.153.4\n", - "matching stock Codex 0.153.4 helper required" - ); - Ok(()) - } -} - -fn clean_absolute(path: &Path) -> bool { - path.is_absolute() - && path - .components() - .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_existing_rpc_arguments_and_rejects_unknown_modes() -> Result<()> { - let args = Cli::try_parse_from([ - "harness", - "-c", - "model=example", - "app-server", - "--stdio", - "--disable", - "multi_agent", - ])?; - let config = args.overrides()?; - assert_eq!( - config.raw_overrides, - ["model=example", "features.multi_agent=false"] - ); - assert!( - Cli::try_parse_from(["harness", "app-server", "--listen", "ws://0.0.0.0:1"]).is_err() - ); - assert!(Cli::try_parse_from(["harness", "exec"]).is_err()); - Ok(()) - } -} diff --git a/packages/codex-harness/src/options_write.rs b/packages/codex-harness/src/options_write.rs deleted file mode 100644 index 7789e43e7..000000000 --- a/packages/codex-harness/src/options_write.rs +++ /dev/null @@ -1,113 +0,0 @@ -use anyhow::{Context, Result, bail, ensure}; -use std::path::{Path, PathBuf}; - -#[derive(Clone)] -pub struct WriteBinding { - pub helper: PathBuf, - pub staging: PathBuf, - pub parent: PathBuf, -} - -impl WriteBinding { - pub fn from_environment(workspace: &Path) -> Result> { - let helper = std::env::var_os("PARSAR_CODEX_HARNESS_WRITE_HELPER").map(PathBuf::from); - let staging = std::env::var_os("PARSAR_CODEX_HARNESS_STAGING").map(PathBuf::from); - Self::from_paths(workspace, helper, staging) - } - - pub fn from_paths( - workspace: &Path, - helper: Option, - staging: Option, - ) -> Result> { - let (helper, staging) = match (helper, staging) { - (None, None) => return Ok(None), - (Some(helper), Some(staging)) => (helper, staging), - _ => bail!("write helper and protected staging must be configured together"), - }; - ensure!( - clean(workspace) && clean(&helper) && clean(&staging), - "write binding requires clean absolute paths" - ); - let parent = workspace - .parent() - .filter(|parent| *parent != Path::new("/")) - .context("workspace must have a non-root Environment parent")?; - ensure!( - staging.parent() == Some(parent) && staging != workspace, - "workspace and staging must be distinct siblings below one private Environment parent" - ); - let parent = parent.to_owned(); - ensure!( - !helper.starts_with(&parent), - "write helper must be outside the writable Environment parent" - ); - Ok(Some(Self { - helper, - staging, - parent, - })) - } -} - -fn clean(path: &Path) -> bool { - path.to_str().is_some_and(|value| { - value.starts_with('/') - && !value.contains(['\\', '\0', '\r', '\n']) - && value - .split('/') - .skip(1) - .all(|part| !part.is_empty() && part != "." && part != "..") - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_explicit_disjoint_siblings_with_external_helper_are_admitted() { - let workspace = Path::new("/data/workspace"); - assert!( - WriteBinding::from_paths(workspace, None, None) - .unwrap() - .is_none() - ); - for (helper, staging) in [ - (Some("/bin/helper"), None), - (None, Some("/data/staging")), - (Some("/data/helper"), Some("/data/staging")), - (Some("/bin/helper"), Some("/data/workspace")), - (Some("/bin/helper"), Some("/data/workspace/staging")), - (Some("/bin/helper"), Some("/other/staging")), - (Some("/bin//helper"), Some("/data/staging")), - (Some("/bin/helper"), Some("/data/../staging")), - ] { - assert!( - WriteBinding::from_paths( - workspace, - helper.map(Into::into), - staging.map(Into::into) - ) - .is_err() - ); - } - assert!( - WriteBinding::from_paths( - Path::new("/workspace"), - Some("/bin/helper".into()), - Some("/staging".into()) - ) - .is_err() - ); - assert!( - WriteBinding::from_paths( - workspace, - Some("/bin/helper".into()), - Some("/data/staging".into()) - ) - .unwrap() - .is_some() - ); - } -} diff --git a/packages/codex-harness/src/owner.rs b/packages/codex-harness/src/owner.rs deleted file mode 100644 index eb036de0a..000000000 --- a/packages/codex-harness/src/owner.rs +++ /dev/null @@ -1,81 +0,0 @@ -use anyhow::{Context, Result}; -use std::future::Future; -use tokio_util::sync::CancellationToken; - -/// Stop file admission with the runner, retaining the admitted operation's wait. -/// The file service owns its existing deadline; this does not reset that budget. -pub async fn supervise( - runner: impl Future>, - files: impl Future>, - stopping: &CancellationToken, -) -> Result<()> { - tokio::pin!(runner, files); - tokio::select! { - biased; - result = &mut runner => { - stopping.cancel(); - files.await.context("private file operation did not drain")?; - result - }, - result = &mut files => result.context("private metadata endpoint stopped"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::sync::oneshot; - - #[tokio::test] - async fn runner_failure_waits_for_drain_and_remains_a_failure() -> Result<()> { - let stopping = CancellationToken::new(); - let (finish, finished) = oneshot::channel(); - let operation = supervise( - async { anyhow::bail!("runner failed") }, - async { - stopping.cancelled().await; - finished.await?; - Ok(()) - }, - &stopping, - ); - tokio::pin!(operation); - assert!(futures::poll!(&mut operation).is_pending()); - assert!(stopping.is_cancelled()); - finish.send(()).expect("drain still owned"); - assert_eq!(operation.await.unwrap_err().to_string(), "runner failed"); - Ok(()) - } - - #[tokio::test] - async fn unresolved_drain_is_not_clean_runner_exit() { - let stopping = CancellationToken::new(); - let result = supervise( - async { Ok(()) }, - async { - stopping.cancelled().await; - anyhow::bail!("native response unresolved") - }, - &stopping, - ) - .await; - let error = result.unwrap_err(); - assert_eq!(error.to_string(), "private file operation did not drain"); - assert_eq!(error.root_cause().to_string(), "native response unresolved"); - } - - #[tokio::test] - async fn file_failure_still_stops_the_runner() { - let stopping = CancellationToken::new(); - let result = supervise( - std::future::pending(), - async { anyhow::bail!("native deadline") }, - &stopping, - ) - .await; - assert_eq!( - result.unwrap_err().root_cause().to_string(), - "native deadline" - ); - } -} diff --git a/packages/codex-harness/src/read_profile.rs b/packages/codex-harness/src/read_profile.rs deleted file mode 100644 index 463edc9c2..000000000 --- a/packages/codex-harness/src/read_profile.rs +++ /dev/null @@ -1,87 +0,0 @@ -use anyhow::{Result, ensure}; -use codex_config::LoaderOverrides; -use std::path::Path; - -// Read preparation has no execution configuration. Keep native security -// requirements, while excluding host/user/project model, MCP and plugin settings. -pub fn loader(home: &Path) -> Result { - loader_with_legacy_path(home, Path::new("/etc/codex/managed_config.toml")) -} - -fn loader_with_legacy_path(home: &Path, legacy: &Path) -> Result { - // Native legacy config also supplies enforced requirements. Do not silently - // remove those constraints while isolating execution configuration. - ensure!( - !legacy.try_exists()?, - "read preparation requires separate native requirements, not legacy managed config" - ); - ensure!( - home.is_absolute(), - "read preparation requires an absolute home" - ); - let empty = home.join("read-config.toml"); - std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&empty)?; - Ok(LoaderOverrides { - system_config_path: Some(empty.clone()), - managed_config_path: Some(empty), - ignore_user_config: true, - ignore_project_config: true, - ..LoaderOverrides::default() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::config::{ConfigBuilder, ConfigOverrides}; - - #[test] - fn rejects_legacy_requirements_instead_of_dropping_them() -> Result<()> { - let state = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join(".parsar"); - let root = tempfile::Builder::new() - .prefix("read-legacy-") - .tempdir_in(state)?; - let legacy = root.path().join("managed_config.toml"); - std::fs::write(&legacy, "approval_policy = 'never'\n")?; - assert!(loader_with_legacy_path(root.path(), &legacy).is_err()); - assert!(!root.path().join("read-config.toml").exists()); - Ok(()) - } - - #[tokio::test] - async fn ignores_execution_layers_but_keeps_security_requirements() -> Result<()> { - let state = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join(".parsar"); - let root = tempfile::Builder::new() - .prefix("read-config-") - .tempdir_in(state)?; - let home = root.path().join("home"); - let project = root.path().join("project"); - std::fs::create_dir_all(&home)?; - std::fs::create_dir_all(project.join(".codex"))?; - let sentinel = - "model = 'must-not-load'\n[mcp_servers.sentinel]\ncommand = '/must-not-run'\n"; - std::fs::write(home.join("config.toml"), sentinel)?; - std::fs::write(project.join(".codex/config.toml"), sentinel)?; - let overrides = loader(&home)?; - assert_eq!(overrides.system_config_path, overrides.managed_config_path); - assert!(std::fs::read(overrides.system_config_path.as_ref().unwrap())?.is_empty()); - assert!(!overrides.ignore_managed_requirements); - assert!(!overrides.ignore_login_requirements); - assert!(overrides.system_requirements_path.is_none()); - let config = ConfigBuilder::default() - .codex_home(home) - .loader_overrides(overrides) - .harness_overrides(ConfigOverrides { - cwd: Some(project), - ..Default::default() - }) - .build() - .await?; - assert_ne!(config.model.as_deref(), Some("must-not-load")); - assert!(config.mcp_servers.get().is_empty()); - Ok(()) - } -} diff --git a/scripts/build-agents-executor.sh b/scripts/build-agents-executor.sh index 5595b16e3..235da0bdc 100755 --- a/scripts/build-agents-executor.sh +++ b/scripts/build-agents-executor.sh @@ -35,8 +35,8 @@ cp -R "$repo_root/packages/codex-executor/src" "$build_context/src" cargo build --locked --release ) mkdir -p "$output_dir" -for binary in agents-api-codex-executor agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do +for binary in agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do cp "$CARGO_TARGET_DIR/release/$binary" "$output_dir/$binary.tmp" mv -f "$output_dir/$binary.tmp" "$output_dir/$binary" done -printf 'Standalone Codex executor and workspace helpers: %s\n' "$output_dir" +printf 'Standalone workspace helpers: %s\n' "$output_dir" diff --git a/scripts/build-agents-harness.sh b/scripts/build-agents-harness.sh deleted file mode 100755 index d21c783fb..000000000 --- a/scripts/build-agents-harness.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -package="$repo_root/packages/codex-harness" -runtime_root="$HOME/.parsar" -output_dir="${AGENTS_HARNESS_BUILD_DIR:-$runtime_root/build/agents-harness}" -native_source="${AGENTS_HARNESS_NATIVE_SOURCE:?Set AGENTS_HARNESS_NATIVE_SOURCE to the pinned upstream Git checkout}" -mode="${1:-build}" -if [[ "$mode" != build && "$mode" != check ]]; then - printf 'Expected build or check mode\n' >&2 - exit 1 -fi -if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then - printf 'The private harness supports Linux x86_64\n' >&2 - exit 1 -fi -export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/agents-harness-cargo}" -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/agents-harness-target}" -export TMPDIR="$runtime_root/cache/agents-harness-tmp" -export RUSTUP_TOOLCHAIN="${RUSTUP_TOOLCHAIN:-1.95.0}" -rustc_version="$(rustc --version)" -if [[ "$rustc_version" != 'rustc 1.95.0 '* ]]; then - printf 'The private harness requires rustc 1.95.0\n' >&2 - exit 1 -fi -python3 "$package/prepare.py" --check --check-path "$output_dir" \ - --check-path "$CARGO_HOME" --check-path "$CARGO_TARGET_DIR" \ - --check-path "$TMPDIR" --check-path "$runtime_root/cache/agents-harness-builds" -mkdir -p "$runtime_root/cache/agents-harness-builds" "$TMPDIR" -build_context="$(mktemp -d "$runtime_root/cache/agents-harness-builds/source.XXXXXX")" -trap 'rm -rf "$build_context"' EXIT -python3 "$package/prepare.py" --source "$native_source" --output "$build_context/upstream" -cd "$build_context/upstream/codex-rs" -if [[ "$mode" == check ]]; then - rustfmt --check --edition 2024 app-server/parsar-harness/*.rs exec-server/src/bounded_file_read*.rs - cargo test --locked -p codex-exec-server --lib bounded_file_read - cargo clippy --locked -p codex-exec-server --lib --tests -- -D warnings - cargo test --locked -p codex-app-server --bin parsar-codex-harness - cargo clippy --locked -p codex-app-server --bin parsar-codex-harness -- -D warnings - exit 0 -fi -cargo build --locked --release -p codex-app-server --bin parsar-codex-harness -mkdir -p "$output_dir" -cp "$CARGO_TARGET_DIR/release/parsar-codex-harness" "$output_dir/parsar-codex-harness.tmp" -mv -f "$output_dir/parsar-codex-harness.tmp" "$output_dir/parsar-codex-harness" -python3 - "$build_context/upstream/preparation.json" "$output_dir" <<'PY' -import hashlib -import json -import os -import pathlib -import subprocess -import sys - -record = json.loads(pathlib.Path(sys.argv[1]).read_text()) -output = pathlib.Path(sys.argv[2]) -record["artifact_sha256"] = hashlib.sha256((output / "parsar-codex-harness").read_bytes()).hexdigest() -record["rustc"] = subprocess.check_output(["rustc", "--version"], text=True).strip() -record["cargo"] = subprocess.check_output(["cargo", "--version"], text=True).strip() -record["build_profile"] = "release" -record["profile_overrides"] = {key: value for key, value in os.environ.items() if key.startswith("CARGO_PROFILE_RELEASE_")} -pending = output / "provenance.json.tmp" -pending.write_text(json.dumps(record, indent=2) + "\n") -pending.replace(output / "provenance.json") -PY -printf 'Private Codex harness: %s\n' "$output_dir/parsar-codex-harness" diff --git a/scripts/check-agents-harness.sh b/scripts/check-agents-harness.sh deleted file mode 100755 index 5837393fa..000000000 --- a/scripts/check-agents-harness.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -package="$repo_root/packages/codex-harness" -export PYTHONDONTWRITEBYTECODE=1 -python3 "$package/prepare.py" --check -python3 "$package/prepare_test.py" -bash -n "$repo_root/scripts/build-agents-harness.sh" "$repo_root/scripts/check-agents-harness.sh" diff --git a/services/agents-api/README.md b/services/agents-api/README.md index a42e4e6f6..96da2c5cb 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -4,8 +4,10 @@ Independent execution service implementing part of the pinned OpenAI Agents API. It owns reusable Agents, durable Sessions/Turns/Items, live events, function actions and a daemon execution worker. Public execution supports qualified Codex, Claude Code (`claude_sdk`) and MiniMax Code (`mcode`) profiles through the shared Runtime contract. -The three-harness Linux amd64 Docker V1 MVP and the separate -[E2B V1 deployment](deploy/e2b/README.md) qualification are accepted. +The three-harness Linux amd64 Docker V1 MVP has accepted evidence. V1 user-managed +Runtime enrollment has [recorded real acceptance](../../contracts/agents-api/user-managed-runtime-v1.md) +with explicit deployment coverage. [E2B deployment](deploy/e2b/README.md) is user-managed; +its earlier Core-managed qualification remains historical evidence. It builds and runs with its own PostgreSQL database and credentials; Parsar's product service, frontend and database are not required. @@ -190,7 +192,11 @@ access. `AGENTS_API_ENGINE` defaults to `codex`; use `claude_sdk` for Claude Cod or `mcode` for MiniMax Code. Configure the corresponding qualified Runtime through its [deployment guide](../../contracts/agents-api/README.md#public-engine-profiles). It selects new Sessions independently of the requested -model. Existing Sessions retain their stored engine. +model. Existing Sessions retain their stored engine. Set +`AGENTS_API_HARNESSES=codex,claude_sdk,mcode` to explicitly enable installed profiles +for user-managed enrollment without a managed Provider. This list supplements the +default engine and any managed engine profiles; unknown names fail startup. +Enabling a profile does not install its harness or qualify its deployment. The SDK base URL is `http://127.0.0.1:8091/v1`. Requests require a bearer key. Agents and Vault routes also require `OpenAI-Beta: agents=v1` (set by their SDK @@ -201,7 +207,7 @@ resources); general Files routes do not. Supported operations include: configuration or a saved `agent_id`, field replacements, optional initial text and ordinary or streaming responses. - Session event submission and live streaming, Turn retrieve/list and Items list. -- Environment retrieve for supported Codex self-hosted and three-harness Docker/E2B +- Environment retrieve for three-harness colocated self-hosted and Docker profiles, bounded live file listing, and inline/source copies into qualified local workspaces. Shared Artifacts support capture, list/retrieve/content and deletion independently of the live Runtime after publication. @@ -215,7 +221,7 @@ resources); general Files routes do not. Supported operations include: Execution uses the selected [engine profile](../../contracts/agents-api/README.md#public-engine-profiles), -including `none` and the Codex self-hosted idle-text profile described below. +including `none` and the colocated self-hosted profile described below. Ordinary JSON requests have a 1 MiB body limit; file transfers use the separate bounds in the Files contracts. Session lists support `after`, `limit` (1..100), `order` (`asc`/`desc`) and optional immutable root `agent_id`. The local defaults @@ -227,7 +233,7 @@ public removal: Session/history reads and new input become unavailable. Active work receives a cancellation request; existing streams close on observing removal. Already claimed work may still complete. Creation keys stay reserved; deletion never affects other Sessions, saved Agents or their shared device. Internal records -are retained for execution settlement. Managed Docker/E2B deletion separately revokes +are retained for execution settlement. Managed Docker deletion separately revokes authority and reclaims owned compute/workspace/history; caller-managed compute is not reclaimed by this service. Local repeated deletion returns 404 and creation-key reuse returns 409; exact hosted errors and overlapping stream timing are unverified. @@ -249,13 +255,14 @@ it executable. Unsupported requests fail explicitly. `/healthz` reports liveness The basic `openai_hosted` profiles for Codex, Claude Code and MiniMax Code require explicit operator configuration. Select the qualified native image using the [engine profile guides](../../contracts/agents-api/README.md#public-engine-profiles), -then follow the [Docker setup](deploy/codex/README.md#standalone-operator-configuration) -or [E2B template/provider setup](deploy/e2b/README.md). +then follow the [Docker setup](deploy/codex/README.md#standalone-operator-configuration). +Core manages Docker only. For user-managed E2B, see +[E2B Runtime packaging](deploy/e2b/README.md). Core remains independently deployed with its own database. Public idle and initial text Sessions share the existing preparation, execution, Files and recovery paths. -Networking defaults to enabled; disabled is also supported after setup completes. -Restricted domains, system packages and remaining unsupported startup installations -remain gaps. Initial inline/file_id files, confidential env, npm/Python packages, +Networking defaults to enabled; disabled and exact-domain restricted policy are +supported after setup. System/npm/Python packages use the shared initializer; +remaining unsupported combinations are explicit gaps. Initial inline/file_id files, confidential env, npm/Python packages, ordered setup and [public Environment Templates](../../contracts/agents-api/environment-templates.md) resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses require separate integration and qualification. @@ -267,8 +274,9 @@ Exact hosted failure/expiry semantics remain unverified. The standalone service can accept existing daemon connections without a Parsar workspace or product database. Enable its internal gateway by setting `AGENTS_API_DAEMON_WS_URL=wss://your-service/api/v1/agent-daemon/ws` (use `ws` -for local development). This is separate from the official Agents API executor -contract; do not return this URL as a public `self_hosted` environment's remote URL. +for local development). This configured URL is returned unchanged as +`self_hosted.remote_url`. It names +our private daemon transport, not stock OpenAI `exec-server` interoperability. After migrations, an operator can provision a device for an execution tenant: @@ -299,8 +307,9 @@ device, preserves that assignment across retries/restarts, and refuses a silent move to another device. Revoked bindings cannot be used for dispatch. Device connections alone do not start a Turn. Submit text, cancellation or function results through the official Session events endpoint; the worker assigns a same-tenant host and preserves that -binding. Managed Docker/E2B lifecycle is qualified within the three-harness V1 profiles; -additional provider qualification and full protocol semantics remain separate. See the [ownership rules](../../CONTRIBUTING.md#product-and-execution-service-separation). +binding. Managed Docker has three-harness evidence. This generic device provisioning path +is for `none`; self-hosted Sessions require the dedicated enrollment below. +User-managed enrollment has a separate [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md); complete protocol semantics remain partial. See the [ownership rules](../../CONTRIBUTING.md#product-and-execution-service-separation). ### Enable Claude SDK execution @@ -448,21 +457,23 @@ Fresh installations and already indexed history need no backfill. Recovery reads continue to use Session/Turn/Items; this procedure is an upgrade operation, not an official SSE replay mechanism. -## Native executor transport prerequisite +## User-managed Runtime enrollment -The disabled-by-default Codex adapter supports executor registration, harness key -authorization and an opaque native Noise relay. Configured execution and an -executor origin enable public `self_hosted` Sessions on Codex. The current -profile requires an absolute `workspace_directory`, empty/default -`capability_directories`, with optional supported non-deferred functions and -service-origin HTTP MCP with optional attached static Bearer credentials. Initial -text is optional. +V1 uses our daemon as the user-side executor. Deploy daemon, selected harness, +local tools and protected `/workspace` together using the shared Runtime. Core +manages Docker-hosted compute only. The user owns local or E2B allocation, renewal +and destruction; use the official E2B SDK through the +[E2B guide](deploy/e2b/README.md), not a Core E2B Provider. -To enable it alongside the existing daemon worker, set -`AGENTS_API_EXECUTOR_URL` to the externally reachable HTTPS origin. Apply the -execution migrations first and start the service once with configured caller -principals to establish its immutable project mappings. Operator database authority -can then issue a connect-only principal key before any Session exists: +Create a Session with `environment={"type":"self_hosted", +"workspace_directory":"/workspace"}` and empty/default capability directories. +Retain the returned Environment ID and unchanged `remote_url`. Initial text may +wait for connection; an idle Session creates no Turn. Codex, Claude SDK and MiniMax +reuse the same exact binding and `LocalEnvironment` preparation path. Service-origin +HTTP MCP is rejected on this placement; `none` MCP and separately qualified hosted +Environment Plugin MCP remain available within their own limits. + +An operator issues a connect-only principal executor key using the existing issuer: ```bash umask 077 @@ -472,167 +483,62 @@ agents-api-environment-key --tenant "$TENANT_ID" \ > "$HOME/.parsar/executor-key.json" ``` -`KEY_ID` is a new canonical nonzero UUID chosen for management and retained by the -operator. Use `--subject-kind user` for a user principal. All identities must be -explicit and match an existing verified project mapping; issuance never creates -or remaps that association. `AGENTS_API_DATABASE_URL` points to the execution DB. -Optionally add `--environment "$ENVIRONMENT_ID"` at issuance to restrict this key -to one existing live Environment with the same recorded Session creator. - -JSON output contains `key_id`, `executor_token`, and `environment_id` only for a -restricted key. Stdout is its only delivery; the -[separate native launcher](../../packages/codex-executor/README.md) consumes this -private file directly. There is no chosen-token import or secret read-back. -Ordinary issuance rejects any existing management ID, including revoked IDs. -Lost output requires explicit `--rotate` with the same full principal and key ID; -`--revoke` invalidates the key without output. Neither operation accepts an -Environment override or changes the key's principal/restriction. Rotation of a -restricted key requires its live owning Session; revocation remains possible after -deletion. Replace the private file and restart executors after rotation. - -The database stores the current digest, immutable typed subject/project partition, -optional restriction, creation/issuance times and revocation marker. Authorization -requires the target Session's project and recorded creator to match. A principal -key can serve multiple matching Sessions; deleting one denies that target without -revoking access to the others. Unknown historical creators never authorize an -executor. Caller, daemon, harness and executor keys have distinct purposes. -Executor keys have no five-minute grant expiry. A restart preserves keys but -invalidates registrations and URL grants, requiring re-registration. Current-key -checks apply to requests and upgrades; authorization heartbeats close existing -pairs every five seconds with a four-second check budget. Connection closure does -not establish native process quiescence. - -**Principal-key cutover:** stop older registries and operator writers, apply -migration 26, and deploy the updated service, issuer and launcher together. Legacy -digests, Environment restrictions and issuance times remain, but keys are revoked -and their principals remain unknown. Their Environment UUIDs remain reserved as -management IDs; they cannot be claimed or rotated into principal keys. Explicitly -issue new keys with new IDs, replace old files and restart executors. Never infer -ownership from old keys or product data. Downgrade refuses to discard principal-key -identities and never undoes legacy revocation. The retired static executor-key -setting remains rejected; there is no old/new authentication fallback. - -Harness credentials are issued internally for an execution owner after checking -the current execution lease and exact tenant/Environment ownership. The registry -holds only bounded process-local digests. The owner retains its credential through -preparation and the transferred Run, then releases it; cancellation and service -shutdown also revoke access and close that credential's pair. Connection tickets -still expire after five minutes, independently of the active owner's lifetime. -See the [canonical ownership rules](../../CONTRIBUTING.md#environment-ownership-and-placement). - -**Transition from static harness keys:** stop the old registry, remove -`AGENTS_API_HARNESS_KEYS_FILE`, retire its secrets/files and restart. That setting -now fails startup rather than retaining a static fallback. With the daemon gateway -and executor URL configured, the service Worker issues and releases these credentials -for pending Environment inputs, selecting a capable tenant device once for an -unbound Session and retaining existing bindings. There is no public harness-key endpoint or user/service-account -identity equivalence. Caller, device, executor and harness credentials stay separate. - -Native routes live outside `/v1/agents`: `POST /cloud/environment/{id}/register` -uses the executor credential; `/connect` uses the harness credential and `/validate` -uses the executor credential. The returned WebSocket URLs carry separate connection -capabilities. Keep URLs and `harness_key_authorization` private; redact query -strings in external access logs. This native adapter does not expand the pinned -public SDK OpenAPI surface. HTTP is -allowed only on loopback for development. Production TLS termination remains an -operator responsibility and requires deployment validation. - -Authenticated socket observations now persist connection state and immutable -Environment event snapshots. These observations also back resource status reads, -without establishing native readiness. Registration replacement -and numbered callbacks fence old observations; a new Worker reconciles previous -process state before opening connections. Shutdown drains observations before -releasing execution ownership. Persistence failures close the registry and require -a service restart; review its lifecycle error logs rather than treating closure as -a successful write. See the [lifecycle rules](../../CONTRIBUTING.md#environment-ownership-and-placement). - -When the executor origin is configured, Session GET/list/metadata and live SSE can -expose `self_hosted` Sessions through a safe output projection. Waiting input requests `environment_connection` before any Turn; -connection arrival clears the action, and the existing Worker still verifies -native readiness before admission. No waiting input means no connection request. -The returned `remote_url` is the configured executor origin. Use that exact URL and -Environment ID with the [pinned caller-started launcher](../../packages/codex-executor/README.md). -Later idle text-message batches wait for durable preparation/admission before the -input endpoint returns 204, even if the executor is already connected. Set client -and proxy timeouts above five minutes; the service gives this response six minutes. -Explicit retry keys preserve the original input identity and deadline. A disconnected -HTTP observer does not cancel the reservation. Local expiry/cancellation errors are -409 `environment_input_expired` / `environment_input_cancelled`; ownership loss is -503 `execution_unavailable`. Exact hosted status/body parity remains unverified. -Initial text on ordinary or streamed creation commits its reservation and returns -the connection target promptly while offline. Connect using that target; the same -Worker prepares and starts the initial Turn. A disconnected creation stream does -not cancel the reservation. Initial expiry leaves a queryable failed Session with -a safe error and no Turn; exact hosted error/timing parity remains unverified. -Cancellation-only events use the existing durable receipt path, including idle -no-Turn requests and retries that never retarget later work. A new cancellation -still conflicts with pending pre-Turn input. HTTP 204 acknowledges admission; -observe completion through events/queries and process cessation separately. -Function definitions use the existing callback bridge. Submit result-only batches -with explicit Turn/call identities; they create no Turn or preparation and retain -the same identities on retries after completion or during later work. Pending -reservations still block new results. Observe native application through the -existing actions, Items and events; admission alone does not acknowledge application. -Message-only batches append to an existing active Turn or reserve idle work under -the same Session lock. Active input returns after durable admission and uses the -existing native steering receipts; retries keep their original Turn after completion -or during later work. No unlocked activity check can bypass idle preparation. -Mixed events, deferred functions, -nonempty capability directories, other placements, populated installation metadata -remain unavailable in this self-hosted profile. Public Environment Templates apply only -to hosted Sessions; Files coverage is recorded in the shared contract assessment. - -Retrieve the returned Environment with -`client.beta.agents.environments.retrieve(session.environment.id)`. This read uses -durable status and the owning live Session's project authorization, even when -execution/registry configuration is disabled. Its required `files`, `plugins` and -`skills` arrays are empty for the supported configuration, which has no API-managed -installations. They do not list caller-prepared or model-created workspace files, -or report native capability discovery. Unsupported stored installation configurations -are rejected rather than reported as empty. The response contains no credentials, -private configuration or file contents. See the -[resource boundary](../../CONTRIBUTING.md#environment-ownership-and-placement). - -The adapter checks its execution lease and visible Environment on requests and -five-second heartbeats; deleted ownership, shutdown or lost ownership closes -connections. Re-registering replaces an old socket without allowing its late close -to clear the replacement. A live credential can register again after replacement. -No command replay or native process termination is promised. - -Each Environment currently accepts one independent harness connection. Multiple -native commands, processes and file operations share it. A second attachment -receives 409 without evicting the incumbent; `/connect` refresh remains -non-disruptive. Five-minute, one-use harness grants bind both keys and sockets to -the current registration. At most 32 grants are retained per registration; -expired unused grants are pruned, and exhaustion returns 429. Expiration prevents -new attachment/validation without terminating an established pair. - -The relay forwards binary frames unchanged, bounded to the native 256 KiB limit. -A stalled write closes the pair after five seconds, without an application queue. -Either peer disconnecting closes both physical sockets and invalidates outstanding -harness grants. Native recovery may resume a retained Session/process; the service -does not restart commands. Multiplexing independent harnesses, durable native -backup, deployment TLS and arbitrary interrupted-work recovery remain unverified. - -The [native probe](tests/native/relay_probe.rs) exercises commands, a 128 KiB file, -refresh, one retained process across a controlled outage, and fresh file retention. -Build it as the `parsar_relay_probe` example in the pinned Codex Rust workspace -(`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`), with its matching lock/dependencies. -The release source's workspace version labels may need alignment to its manifests; -do not change third-party dependencies. Keep build/runtime files under `~/.parsar/`. -Run `TestNativeHarnessRelayPostgreSQLAndProcessRecovery` with the dedicated execution -test DB, `PARSAR_CODEX_BINARY` (0.153.4), `PARSAR_NATIVE_RELAY_PROBE` (that example) -and `PARSAR_EXECUTOR_PROOF_DIR` (private output directory). Without those native -prerequisites that test skips; the regular authorization/relay/Store checks still -run. This transport proof makes zero model calls; public model execution through -Environment remains a separate required acceptance workflow. - -The pinned Codex 0.153.4 CLI accepts registry API-key authentication on loopback but -protects OpenAI credentials from third-party production domains. The separately -named [upstream-library launcher](../../packages/codex-executor/README.md) provides -an explicit service-credential path. It keeps the stock guard intact and does not -establish the documented stock command on an arbitrary production domain. -See [Environment contracts and remaining work](../../contracts/agents-api/environments.md). +The immutable principal must match a verified project mapping and the Session's +recorded creator. Add `--environment "$ENVIRONMENT_ID"` to restrict a key to one +live Environment. Keep the one-time `executor_token` output private. Only its digest +is stored; there is no secret read-back. `--rotate` and `--revoke` require the same +management ID and full principal; neither changes the key's restriction. Unknown +historical creators cannot enroll. API bearer keys and executor keys are separate. + +Inside the qualified Linux Runtime, install that JSON as an owned mode-0600 +`$PARSAR_HOME/parsar-daemon/executor-key.json`, outside the tool workspace. The +packaged Runtime supplies its native harness, filesystem helpers and isolation +profile. Start its daemon with the values returned by Session creation: + +```bash +parsar-daemon connect --remote "$REMOTE_URL" \ + --environment-id "$ENVIRONMENT_ID" \ + --credential-file "$PARSAR_HOME/parsar-daemon/executor-key.json" +``` + +Use TLS outside loopback. Enrollment supplies the trusted Session identity; do +not inject an unrelated `PARSAR_RUNTIME_SESSION_ID`. The daemon persists its +immutable Environment binding beside the key and refuses to adopt another +Environment's existing native history. Rotation keeps the same key ID: update the +protected file, then restart the daemon to authenticate with the new token. +WebSocket authentication uses the `Authorization` header, never a URL token. + +The private `POST /api/v1/agent-daemon/enroll` endpoint accepts that executor bearer +and `{"environment_id":"..."}`. It returns `device_id`, `session_id`, +`environment_id` and `workspace_directory`, never another credential. Enrollment +atomically binds one dedicated device/key to the Session; retries retain it and +conflicting bindings reject. No managed `runtime_allocation` is created. Runtime +onboarding connects to the returned `remote_url` using that exact binding and key. +The endpoint is outside the pinned public Agents API, which remains unchanged. +It is not stock `exec-server`, `/cloud/environment/*` or Noise interoperability; +there is no service-side harness or remote tool forwarding compatibility path. + +The gateway and Worker recheck current credential authority. Rotation/revocation, +Session deletion and lost ownership deny further use; a replacement connection +cannot overwrite a successor's observations. Connection events report authenticated +connectivity, not native preparation readiness. Retained native history and workspace +must survive a Runtime restart for continuation; missing history fails closed. +Neither disconnect nor deletion promises immediate native process quiescence or +reclaims user-owned E2B/local compute. The user must stop and destroy it explicitly. + +Pending input retains its durable identity/deadline through HTTP disconnects. Later +idle input returns 204 after preparation/admission, not after model completion; +use client/proxy timeouts above five minutes and recover progress through events +and reads. Exact upstream failure/error timing remains unverified. + +The former registry/Noise relay, temporary harness credentials, separate native +executor launcher, private Codex harness package and old remote native probes are +retired. The Rust package retains only directory, write and workspace-export +helpers. Historical acceptance remains evidence for its original topology, not +proof of this new enrollment chain. The [current qualification record](../../contracts/agents-api/user-managed-runtime-v1.md) +identifies the separate fixed-SDK/raw HTTP, real-model, Files/Artifacts, +cancellation, restart/history and credential lifecycle evidence. + ### HTTP MCP execution @@ -640,8 +546,7 @@ This section covers `agent.tools` with `connection_origin: "service"`. Environment-origin Plugin declarations use the separate [initialization and transport contract](../../contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins). -Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}` -or a `self_hosted` Environment; Claude SDK supports HTTP MCP with +Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}`; Claude SDK supports HTTP MCP with `environment:{"type":"none"}`. Inline or saved Agent tools may declare: ```json @@ -669,7 +574,8 @@ work can already be accepted or queued during this wait. Exact hosted creation timing/errors and continuing MCP health monitoring remain unverified. On `environment:none`, both Codex and Claude SDK support tenant-owned `vault_ids` -for static-bearer HTTPS MCP; Codex also supports the `self_hosted` combination. +for static-bearer HTTPS MCP. `self_hosted` is explicitly unsupported for +service-origin MCP in V1, including anonymous requests. An explicit `credential_id` selects an attached credential for the exact HTTPS URL; omission/null selects a unique matching credential, or stays anonymous if none matches. Ambiguity @@ -678,20 +584,13 @@ the caller's original credential field. See [credential setup and limits](creden Authenticated execution additionally requires `mcp_http_bearer_auth`; missing keys or failed authorization/decryption never fall back to anonymous execution. -With `self_hosted`, commands use the registered executor while MCP connections -remain on the trusted service harness. This combination additionally -requires `mcp_http_remote_environment` and the existing remote preparation -capabilities; separate MCP/remote support on an older daemon does not imply this -combination. A selected Vault credential also requires `mcp_http_remote_bearer_auth`; -older peers with only separate MCP/remote/bearer capabilities cannot receive it. -Secrets enter only the service native process environment, not the executor or -public/native history. Unmatched attached Vaults may retain an anonymous selection. -Both native remote readiness and MCP configuration -checks run before thread creation/resume. +The colocated V1 `self_hosted` profile does not admit service-origin MCP. The old +separate executor/service-side MCP combination and its remote capability gates are +retired. Do not forward Vault credentials to user-owned Runtime compute. Claude SDK requires a packaged runtime that reports `mcp_http_tools`; the SDK version alone does not qualify an older bundle. Its current profile -requires `required:false`, connected servers and static inventories. Server labels +accepts either `required` value, requires connected servers and static inventories. Server labels accept ASCII letters, digits, underscore and hyphen, except reserved `functions`; selected tool names additionally accept dots. Declared HTTP MCP tools compose with host functions; undeclared servers, built-ins and subagents remain disabled. diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index 0556566b4..223c57293 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -33,8 +33,8 @@ detect changed bytes; obtain the archive and checksum from a trusted distributor Provision a dedicated PostgreSQL database and account. Use neither the product database nor its migrations. The following local example assumes an unused port -8091. For remote clients, place the API behind TLS and set both advertised URLs to -the corresponding reachable service addresses. +8091. For remote clients, place the API behind TLS and set the advertised daemon URL to +the reachable WSS service address. ```sh umask 077 @@ -64,11 +64,10 @@ project and subject IDs must remain stable across key rotation. For the Docker variant, continue in `HOSTED.md` now to configure the Runtime's outward connection and provider before starting Core. For the basic archive, -set the separately installed software's reachable endpoints: +set the reachable daemon endpoint: ```sh export AGENTS_API_DAEMON_WS_URL=ws://127.0.0.1:8091/api/v1/agent-daemon/ws -export AGENTS_API_EXECUTOR_URL=http://127.0.0.1:8091 ``` Keep the configuration and key files mode 0600. Run migrations explicitly, then @@ -84,36 +83,6 @@ project mappings required by the operator commands. One API execution worker own each database; starting replicas does not provide execution HA. Native history belongs to the harness host and must survive API replacement. -## Connect execution software - -Keep the API running. In a separate operator shell with the same private database -configuration, provision a new daemon profile and an executor principal key using -the IDs from `keys.json`. `KEY_ID` is a new canonical nonzero UUID retained for -future rotation/revocation. The executor key can be issued before any Session. - -```sh -umask 077 -mkdir -p "$PARSAR_HOME/parsar-daemon/agents-api" -"$AGENTS_API_BIN_DIR/agents-api-device" \ - --tenant "$TENANT_ID" --name 'Agents API harness' \ - --url http://127.0.0.1:8091 \ - > "$PARSAR_HOME/parsar-daemon/agents-api/auth.json" -"$AGENTS_API_BIN_DIR/agents-api-environment-key" \ - --tenant "$TENANT_ID" --organization "$ORGANIZATION_ID" \ - --project "$PROJECT_ID" --subject-kind service_account \ - --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" \ - > "$PARSAR_HOME/executor-key.json" -``` - -Use new private files; do not overwrite an existing device profile or key output. -Install the daemon, matching native Codex 0.153.4 resources and -[native executor launcher](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/packages/codex-executor/README.md) -separately. In the daemon's own service environment, configure native model access -and run `parsar-daemon connect --profile agents-api` with the provisioned profile. -Transfer the profile securely if the harness runs on another host. The harness -must not inherit the operator database or caller credentials. Provider credentials -belong in its private native configuration, outside caller executor compute. - ## Use the public client Install the official Python client at the commit in `manifest.json` (SDK 3.13.0). @@ -131,19 +100,41 @@ session = client.beta.agents.sessions.create( print(session.id, session.environment.id, session.environment.remote_url) ``` -On caller-controlled executor compute, prepare `/workspace` and connect the -separately installed launcher using the returned target. Transfer only its scoped -`executor-key.json`; do not transfer caller, database, daemon or model credentials. +Keep the API running. In a separate operator shell with the private database +configuration, issue an executor key restricted to this Environment using the +principal IDs from `keys.json`. `KEY_ID` is a new canonical nonzero UUID retained +for rotation/revocation. Save the output to a new private file: + +```sh +umask 077 +"$AGENTS_API_BIN_DIR/agents-api-environment-key" \ + --tenant "$TENANT_ID" --organization "$ORGANIZATION_ID" \ + --project "$PROJECT_ID" --subject-kind service_account \ + --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" --environment "$ENVIRONMENT_ID" \ + > "$PARSAR_HOME/executor-key.json" +``` + +Deploy the qualified V1 Runtime containing our daemon, selected native harness, +local tools and workspace. Transfer only its scoped key into the protected daemon +state directory as an owned mode-0600 file. Keep API caller and database credentials +outside Runtime. Configure the model through the existing private adapter options; +native tools must not inherit model credentials or read native history. + +Inside that Runtime, use the exact values returned by Session creation: ```sh -agents-api-codex-executor --remote "$REMOTE_URL" \ - --environment-id "$ENVIRONMENT_ID" --credentials "$HOME/.parsar/executor-key.json" \ - --codex-bin /opt/codex/bin/codex +parsar-daemon connect --remote "$REMOTE_URL" \ + --environment-id "$ENVIRONMENT_ID" \ + --credential-file "$PARSAR_HOME/parsar-daemon/executor-key.json" ``` -A directory or key binding does not isolate files or same-user processes. Use an -appropriate separate runtime when isolation is required. HTTP is accepted only -for loopback development; a remote executor needs the reachable HTTPS target. +The daemon fills the executor role. No separate Codex executor or service-side +harness is required. This is our private daemon transport, not stock exec-server +wire interoperability. Use WSS outside loopback. Runtime packaging must provide +`/environment/workspace`, its `/workspace` alias, helpers and native isolation; +a directory or key binding alone does not isolate same-user processes. User-owned +E2B deployment uses the [official-SDK startup example](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/services/agents-api/deploy/e2b/README.md). +Core does not allocate or reclaim that compute. In the same Python client, stream a Turn after connecting the executor: @@ -161,7 +152,7 @@ Reuse the database, caller identities, daemon profile and native history. Do not resubmit uncertain execution as new work. Graceful shutdown or connection closure does not by itself prove all native descendants have exited. -For key rotation, device revocation, existing-database upgrades and other supported +For key rotation, executor-key revocation, existing-database upgrades and other supported profiles, use the [versioned service guide](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/services/agents-api/README.md). This package does not install PostgreSQL, daemons, harnesses, TLS or a supervisor, and it does not switch Parsar's product execution path. diff --git a/services/agents-api/cmd/server/environment_connection_test.go b/services/agents-api/cmd/server/environment_connection_test.go deleted file mode 100644 index 2783a00f7..000000000 --- a/services/agents-api/cmd/server/environment_connection_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type connectionStore struct{ tenant, environment string } - -func (s connectionStore) GetEnvironment(ctx context.Context, tenant, environment string) (store.Environment, error) { - if err := ctx.Err(); err != nil { - return store.Environment{}, err - } - if tenant != s.tenant || environment != s.environment { - return store.Environment{}, store.ErrNotFound - } - return store.Environment{ID: environment}, nil -} - -func (connectionStore) AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) { - return "", store.ErrNotFound -} - -func TestEnvironmentConnectionUsesScopedOwnerCredentials(t *testing.T) { - if environmentConnection(nil) != nil { - t.Fatal("disabled registry enabled pending-input scheduling") - } - source := connectionStore{tenant: uuid.NewString(), environment: uuid.NewString()} - owned := true - registry, err := codex.New(codex.Config{Store: source, PublicURL: "https://executor.example/", - ReplaceConnection: func(context.Context, string, string, string) error { - t.Fatal("credential-only fixture replaced a connection") - return nil - }, - ObserveConnection: func(context.Context, string, string, string, int64, bool) error { - t.Fatal("credential-only fixture observed a connection") - return nil - }, - CheckOwnership: func(context.Context) error { - if !owned { - return errors.New("execution ownership lost") - } - return nil - }}) - if err != nil { - t.Fatal(err) - } - defer registry.Close() - resolve := environmentConnection(registry) - session := store.Session{TenantID: source.tenant, Engine: "codex"} - environment := store.Environment{ID: source.environment} - owner, cancel := context.WithCancel(context.Background()) - defer cancel() - first, err := resolve(owner, session, environment) - if err != nil || first.URL != "https://executor.example" || first.Token == "" || first.Release == nil { - t.Fatal("connection did not carry the configured origin and owner credential", err) - } - defer first.Release() - second, err := resolve(owner, session, environment) - if err != nil || second.Token == first.Token { - t.Fatal("independent execution owners reused a credential", err) - } - defer second.Release() - status := func(token string) int { - req := httptest.NewRequest(http.MethodPost, "/cloud/environment/"+source.environment+"/connect", nil) - req.Header.Set("Authorization", "Bearer "+token) - response := httptest.NewRecorder() - registry.Handler().ServeHTTP(response, req) - return response.Code - } - // Authenticated requests reach body validation; there is no executor fixture. - if status(first.Token) != http.StatusBadRequest || status(second.Token) != http.StatusBadRequest { - t.Fatal("issued credentials did not reach their native registry") - } - first.Release() - if status(first.Token) != http.StatusUnauthorized || status(second.Token) != http.StatusBadRequest { - t.Fatal("release did not retain the separate execution owner") - } - foreign := session - foreign.TenantID = uuid.NewString() - if _, err := resolve(owner, foreign, environment); !errors.Is(err, store.ErrNotFound) { - t.Fatal("foreign tenant obtained Environment authority", err) - } - foreign = session - foreign.Engine = "claude_sdk" - if _, err := resolve(owner, foreign, environment); !errors.Is(err, store.ErrInvalidInput) { - t.Fatal("Codex transport accepted another engine", err) - } - cancel() - if status(second.Token) != http.StatusUnauthorized { - t.Fatal("owner cancellation retained its credential") - } - owned = false - failed, err := resolve(context.Background(), session, environment) - if err == nil || failed.Token != "" || failed.Release != nil { - t.Fatal("lost ownership issued a usable connection") - } -} diff --git a/services/agents-api/cmd/server/executor.go b/services/agents-api/cmd/server/executor.go deleted file mode 100644 index 03a2c1222..000000000 --- a/services/agents-api/cmd/server/executor.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "context" - "errors" - "os" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func executorRegistry(s *store.Store, worker func() *execution.Worker, checkOwnership func(context.Context) error) (*codex.Registry, error) { - file, url := os.Getenv("AGENTS_API_EXECUTOR_KEYS_FILE"), os.Getenv("AGENTS_API_EXECUTOR_URL") - harnessFile := os.Getenv("AGENTS_API_HARNESS_KEYS_FILE") - if file == "" && url == "" && harnessFile == "" { - return nil, nil - } - if file != "" { - return nil, errors.New("AGENTS_API_EXECUTOR_KEYS_FILE is retired; issue durable credentials with agents-api-environment-key and remove the old setting") - } - if harnessFile != "" { - return nil, errors.New("AGENTS_API_HARNESS_KEYS_FILE is retired; harness credentials belong to internal execution ownership; remove the old setting") - } - if url == "" || checkOwnership == nil || worker == nil { - return nil, errors.New("executor registry requires URL and the daemon execution worker") - } - return codex.New(codex.Config{Store: s, CheckOwnership: checkOwnership, PublicURL: url, - ReplaceConnection: func(ctx context.Context, tenant, environment, generation string) error { - if worker() == nil { - return errors.New("execution worker is not initialized") - } - return worker().ReplaceEnvironmentConnection(ctx, tenant, environment, generation) - }, - ObserveConnection: func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - if worker() == nil { - return errors.New("execution worker is not initialized") - } - return worker().ObserveEnvironmentConnection(ctx, tenant, environment, generation, revision, connected) - }, - }) -} - -func environmentConnection(registry *codex.Registry) func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { - if registry == nil { - return nil - } - return func(ctx context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { - if session.Engine != "codex" { - return execution.EnvironmentConnection{}, store.ErrInvalidInput - } - token, release, err := registry.IssueHarnessCredential(ctx, session.TenantID, environment.ID) - if err != nil { - return execution.EnvironmentConnection{}, err - } - return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, nil - } -} diff --git a/services/agents-api/cmd/server/executor_test.go b/services/agents-api/cmd/server/executor_test.go deleted file mode 100644 index 69d765418..000000000 --- a/services/agents-api/cmd/server/executor_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "context" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestExecutorRegistryIsExplicitAndRetiresStaticKeys(t *testing.T) { - t.Setenv("AGENTS_API_EXECUTOR_KEYS_FILE", "") - t.Setenv("AGENTS_API_HARNESS_KEYS_FILE", "") - t.Setenv("AGENTS_API_EXECUTOR_URL", "") - if r, err := executorRegistry(nil, nil, nil); err != nil || r != nil { - t.Fatal("default registry must remain disabled") - } - t.Setenv("AGENTS_API_EXECUTOR_URL", "https://executor.example") - if _, err := executorRegistry(nil, nil, nil); err == nil { - t.Fatal("registry enabled without execution owner") - } - s := store.New(nil) - checkOwnership := func(context.Context) error { - t.Fatal("constructor invoked ownership before the worker was initialized") - return nil - } - for _, setting := range []string{"AGENTS_API_EXECUTOR_KEYS_FILE", "AGENTS_API_HARNESS_KEYS_FILE"} { - t.Setenv(setting, "retired-private-file") - if _, err := executorRegistry(s, func() *execution.Worker { return nil }, checkOwnership); err == nil { - t.Fatal("retired key setting accepted", setting) - } - t.Setenv(setting, "") - } - registry, err := executorRegistry(s, func() *execution.Worker { return nil }, checkOwnership) - if err != nil { - t.Fatal(err) - } - if registry.PublicURL() != "https://executor.example" { - t.Fatal("public executor origin differs from configured registration origin", registry.PublicURL()) - } - registry.Close() -} - -func TestExecutorPublicOriginUsesRegistryValidation(t *testing.T) { - t.Setenv("AGENTS_API_EXECUTOR_KEYS_FILE", "") - t.Setenv("AGENTS_API_HARNESS_KEYS_FILE", "") - for _, origin := range []string{ - "https://token@executor.example", "https://executor.example?token=secret", "https://executor.example?", - "https://executor.example/#token", "https://executor.example/daemon", "http://executor.example", "", - } { - t.Setenv("AGENTS_API_EXECUTOR_URL", origin) - registry, err := executorRegistry(store.New(nil), func() *execution.Worker { return nil }, func(context.Context) error { return nil }) - if err == nil && registry != nil { - registry.Close() - t.Fatal("invalid public executor origin accepted", origin) - } - } - for origin, want := range map[string]string{ - "https://executor.example/": "https://executor.example", "http://127.0.0.1:8091/": "http://127.0.0.1:8091", - } { - t.Setenv("AGENTS_API_EXECUTOR_URL", origin) - registry, err := executorRegistry(store.New(nil), func() *execution.Worker { return nil }, func(context.Context) error { return nil }) - if err != nil { - t.Fatal(err) - } - if registry.PublicURL() != want { - t.Fatal("incorrect executor registration origin", registry.PublicURL(), want) - } - registry.Close() - } -} diff --git a/services/agents-api/cmd/server/harnesses.go b/services/agents-api/cmd/server/harnesses.go new file mode 100644 index 000000000..86e7518f6 --- /dev/null +++ b/services/agents-api/cmd/server/harnesses.go @@ -0,0 +1,34 @@ +package main + +import ( + "errors" + "os" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" +) + +// Engine selection is independent of compute ownership. Operators may enable +// user-managed Runtime profiles without configuring a managed Provider. +func enabledHarnesses(defaultEngine string, managed *execution.RuntimeProviders) ([]string, error) { + kinds := []string{defaultEngine} + if managed != nil { + for kind := range managed.EngineProviders { + kinds = append(kinds, kind) + } + } + if value := os.Getenv("AGENTS_API_HARNESSES"); value != "" { + kinds = append(kinds, strings.Split(value, ",")...) + } + for i, kind := range kinds { + kind = strings.TrimSpace(kind) + if _, known := (engine.Catalog{}).Lookup(kind); !known { + return nil, errors.New("unknown configured harness") + } + kinds[i] = kind + } + slices.Sort(kinds) + return slices.Compact(kinds), nil +} diff --git a/services/agents-api/cmd/server/harnesses_test.go b/services/agents-api/cmd/server/harnesses_test.go new file mode 100644 index 000000000..1e6296730 --- /dev/null +++ b/services/agents-api/cmd/server/harnesses_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestUserManagedHarnessSelectionNeedsNoProvider(t *testing.T) { + t.Setenv("AGENTS_API_HARNESSES", "codex,claude_sdk,mcode") + kinds, err := enabledHarnesses("codex", nil) + if err != nil || len(kinds) != 3 { + t.Fatal(kinds, err) + } + t.Setenv("AGENTS_API_HARNESSES", "unqualified") + if _, err = enabledHarnesses("codex", nil); err == nil { + t.Fatal("unqualified harness enabled") + } +} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index c8c9d4699..6d8ec5a4b 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/jackc/pgx/v5/pgxpool" ) @@ -93,35 +94,18 @@ func run() error { options := []api.Option{api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} var daemonHandler http.Handler var registry *gateway.Registry - var checkOwnership func(context.Context) error if wsURL := os.Getenv("AGENTS_API_DAEMON_WS_URL"); wsURL != "" { daemonHandler, registry, err = runtime.NewGateway(executionStore, wsURL) if err != nil { return err } defer runtime.CloseConnections(registry) - // Constructors do not invoke ownership checks; publish only after setup. - checkOwnership = func(ctx context.Context) error { - if worker == nil { - return errors.New("execution worker is not initialized") - } - return worker.CheckOwnership(ctx) - } - } - executor, err := executorRegistry(executionStore, func() *execution.Worker { return worker }, checkOwnership) - if err != nil { - return err - } - if executor != nil { - defer executor.Close() - options = append(options, api.WithEnvironmentRemoteURL(executor.PublicURL())) + options = append(options, api.WithEnvironmentRemoteURL(wsURL)) } if registry != nil { dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, - EnvironmentConnection: environmentConnection(executor), ManagedRuntimes: managed, Options: transientOptions} - if executor != nil { - dispatcher.CloseEnvironmentConnections = executor.Close - } + ManagedRuntimes: managed, Options: transientOptions} + worker, err = execution.StartWorker(ctx, dispatcher) if err != nil { return err @@ -135,26 +119,24 @@ func run() error { } }() options = append(options, api.WithExecution(worker), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) + kinds, err := enabledHarnesses(engine, managed) + if err != nil { + return err + } + options = append(options, api.WithHarnesses(kinds)) if managed != nil && (managed.DefaultProvider != "" || len(managed.EngineProviders) > 0) { - kinds := make([]string, 0, len(managed.EngineProviders)) - for kind := range managed.EngineProviders { - kinds = append(kinds, kind) - } - options = append(options, api.WithHostedEnvironments(), api.WithHarnesses(kinds)) + options = append(options, api.WithHostedEnvironments()) } } handler, err := api.NewHandler(executionStore, auth, engine, options...) if err != nil { return err } - if daemonHandler != nil || executor != nil { + if daemonHandler != nil { mux := http.NewServeMux() - if daemonHandler != nil { - mux.Handle("/api/v1/agent-daemon/", daemonHandler) - } - if executor != nil { - mux.Handle("/cloud/environment/", executor.Handler()) - } + mux.Handle("/api/v1/agent-daemon/", daemonHandler) + mux.Handle("/api/v1/agent-daemon/enroll", runtimeenrollment.EnrollmentHandler(executionStore)) + mux.Handle("/", handler) handler = mux } diff --git a/services/agents-api/cmd/server/managed_runtimes.go b/services/agents-api/cmd/server/managed_runtimes.go index cfeef37c1..cc69d240b 100644 --- a/services/agents-api/cmd/server/managed_runtimes.go +++ b/services/agents-api/cmd/server/managed_runtimes.go @@ -12,7 +12,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" sandboxdocker "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - sandboxe2b "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" "github.com/moby/moby/client" ) @@ -21,13 +20,6 @@ type managedRuntimeConfig struct { EngineProviders map[string]string `json:"engine_providers"` DefaultProvider string `json:"default_provider"` Docker map[string]managedDockerConfig `json:"docker"` - E2B map[string]managedE2BConfig `json:"e2b"` -} - -type managedE2BConfig struct { - APIKeyFile string `json:"api_key_file"` - Template string `json:"template"` - LeaseSeconds int `json:"lease_seconds"` } type managedDockerConfig struct { @@ -57,13 +49,12 @@ func managedRuntimes() (*execution.RuntimeProviders, func(), error) { var config managedRuntimeConfig decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() - if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF || len(config.Docker)+len(config.E2B) == 0 { + if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF || len(config.Docker) == 0 { return nil, closeAll, errors.New("invalid managed Runtime configuration") } if config.DefaultProvider != "" { _, dockerOK := config.Docker[config.DefaultProvider] - _, e2bOK := config.E2B[config.DefaultProvider] - if !dockerOK && !e2bOK { + if !dockerOK { return nil, closeAll, errors.New("managed default provider is not configured") } } @@ -80,8 +71,7 @@ func managedRuntimes() (*execution.RuntimeProviders, func(), error) { for kind, key := range config.EngineProviders { _, qualified := (engine.Catalog{}).Lookup(kind) _, dockerOK := config.Docker[key] - _, e2bOK := config.E2B[key] - if !qualified || key == "" || (!dockerOK && !e2bOK) { + if !qualified || key == "" || (!dockerOK) { return nil, closeAll, errors.New("invalid managed engine provider mapping") } } @@ -92,20 +82,6 @@ func managedRuntimes() (*execution.RuntimeProviders, func(), error) { } } result := &execution.RuntimeProviders{EngineProviders: config.EngineProviders, CoreURL: config.CoreURL, DefaultProvider: config.DefaultProvider, Providers: map[string]sandbox.Provider{}} - for key, entry := range config.E2B { - if _, duplicate := config.Docker[key]; duplicate { - return nil, closeAll, errors.New("managed provider keys must be unique") - } - keyBytes, err := os.ReadFile(entry.APIKeyFile) - if err != nil { - return nil, closeAll, errors.New("cannot read managed E2B API key file") - } - provider, err := sandboxe2b.New(sandboxe2b.Config{InstallationID: key, APIKey: strings.TrimSpace(string(keyBytes)), Template: entry.Template, LeaseSeconds: entry.LeaseSeconds}) - if err != nil { - return nil, closeAll, errors.New("invalid managed E2B provider configuration") - } - result.Providers[key] = provider - } for key, entry := range config.Docker { // V1 qualifies a local Docker daemon. Remote executor/provider transports are // separate work; do not silently inherit a different backend from the shell. diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md index 0411ef0f1..fc1937338 100644 --- a/services/agents-api/deploy/e2b/README.md +++ b/services/agents-api/deploy/e2b/README.md @@ -1,19 +1,21 @@ -# E2B colocated Runtime +# User-managed E2B Runtime -E2B implements the existing SandboxProvider's Create, GetInfo, Renew, Kill and -initialization-only RunCommand. Each sandbox contains the same daemon, native -harness, local tools and workspace as the qualified Docker Runtime. Core remains -independent. Daily execution and public Files/Artifacts use daemon/Runtime; they -never use E2B commands or its filesystem service. +The application creates, renews and destroys its own E2B sandbox using the +maintained E2B SDK. Core receives neither the E2B API key nor an allocation +request. The sandbox runs the existing V1 daemon, selected native harness, tools +and workspace together. Codex, Claude Code and MiniMax Code use the same startup +contract and their respective qualified Runtime images. -## Build and qualify +This deployment uses Parsar daemon enrollment, not Codex `exec-server` or Noise. +Public execution and Files/Artifacts continue through Core and the daemon; +E2B commands/files are used only for application-controlled deployment and +inspection. A public Session deletion does not destroy the user-owned VM. -Use the qualified Linux amd64 Docker image for the selected harness. Build the -E2B template on a machine with Docker and Python 3.12+ using `requirements.txt`. The -builder extracts the existing runtime binaries and native profile; it does not -rebuild the harness or add a tool loop. Archive extraction retains read-only native -configuration; extraction errors must not silently omit the profile. Store private keys and build outputs under -`~/.parsar/` and keep them out of the checkout. +## Build the packaged Runtime + +Use Python 3.12+, Docker and a qualified Linux amd64 Runtime image containing the +environment-aware daemon `connect` command. Keep keys and build outputs outside +the checkout, in private directories. Install the pinned SDK from this directory: ```sh python -m venv "$HOME/.parsar/build/e2b-sdk" @@ -25,120 +27,123 @@ python -m venv "$HOME/.parsar/build/e2b-sdk" --output "$HOME/.parsar/build/e2b-template.json" ``` -The output's `template` is the official immutable `templateID:build_UUID` -reference. Qualify and deploy that exact reference, never a mutable alias or a -fallback template. Python and the E2B SDK are build/acceptance tools only; the -production Core uses Go and authenticated official REST/Connect transports. - -## Operator configuration - -Set `AGENTS_API_MANAGED_RUNTIMES_FILE` to a private JSON file: - -```json -{ - "core_url": "https://core.example.com/api/v1", - "default_provider": "7d7527e1-c198-4d6a-a807-c4b90e89acb4", - "e2b": { - "7d7527e1-c198-4d6a-a807-c4b90e89acb4": { - "api_key_file": "/private/e2b.key", - "template": "TEMPLATE_ID:BUILD_UUID", - "lease_seconds": 7200 - } - } -} +The builder preserves the existing image's binaries, native configuration and +private workspace layout. Its `template` output is an immutable +`templateID:build_UUID`; use that exact value. Each engine needs its qualified +image/build. No E2B account key, executor key or model credential belongs in a +build, template environment, metadata, command argument or log. + +## Start an existing self-hosted Environment + +Create a public `self_hosted` Environment through Core and retain its ID and exact +returned `remote_url`. Obtain an authorized connect-only executor key scoped to +that Environment (or its owning principal) through the operator credential flow. +The key JSON is `{"key_id":"UUID","executor_token":"SECRET"}` with an optional +`environment_id` restriction. Store both this JSON and the separate E2B API key +in private files with mode `0600`. + +The current packaged profile uses public `/workspace`, backed by +`/environment/workspace`. The remote endpoint must be reachable from the VM; +use the returned `wss://.../api/v1/agent-daemon/ws` unchanged. The native profile +comes from the image, and model credentials arrive through authenticated Core +execution. Do not supply the old Core allocation/Bootstrap JSON or `auth.json`. + +Generate and retain an application launch UUID once. `launch.py` is a thin SDK +example, not a service or a replacement lifecycle owner: + +```sh +"$HOME/.parsar/build/e2b-sdk/bin/python" services/agents-api/deploy/e2b/launch.py \ + --template 'TEMPLATE_ID:BUILD_UUID' \ + --remote-url 'RETURNED_REMOTE_URL' \ + --environment-id 'RETURNED_ENVIRONMENT_UUID' \ + --launch-id 'YOUR_APPLICATION_LAUNCH_UUID' \ + --executor-key-file "$HOME/.parsar/secrets/executor-key.json" \ + --api-key-file "$HOME/.parsar/secrets/e2b.key" \ + --record "$HOME/.parsar/runtimes/YOUR_APPLICATION_LAUNCH_UUID.json" \ + --timeout 7200 +``` + +Choose a lease supported by your E2B account and renew it before expiry. The +example creates an exclusive private launch record before Create, stores the +returned sandbox ID before startup, and sets `on_timeout=kill` with auto-resume +disabled. Metadata contains only the application launch ID and Environment ID. +It never repeats Create/start, replaces a sandbox or deletes failure evidence. +Reusing the record path rejects before any cloud call. Do not bypass that guard +by supplying a new path after an uncertain result. + +## Inspect, renew and destroy + +The application remains responsible for the lease and cleanup, including after +Session deletion or daemon failure. These SDK calls use the retained exact ID +and do not connect to, resume or recreate a sandbox: + +```python +import json +from pathlib import Path +from e2b import Sandbox + +record = json.loads(Path('/private/launch.json').read_text()) +api_key = Path('/private/e2b.key').read_text().strip() +sandbox_id = record['sandbox_id'] +info = Sandbox.get_info(sandbox_id, api_key=api_key) +assert info.metadata['parsar_launch_id'] == record['launch_id'] +assert info.metadata['parsar_environment_id'] == record['environment_id'] +Sandbox.set_timeout(sandbox_id, 7200, api_key=api_key) # When renewing the live VM. +# When the application is finished, or explicitly abandons this allocation: +Sandbox.kill(sandbox_id, api_key=api_key) ``` -Set `AGENTS_API_DAEMON_WS_URL` to -`wss://core.example.com/api/v1/agent-daemon/ws`. Both endpoints must be reachable -from E2B. Select the existing `AGENTS_API_ENGINE` and corresponding private model -provider configuration for the qualified image. No public engine selector is -introduced. Docker and E2B entries share the provider-key namespace; retained -entries remain available for existing allocations and cleanup. Use a new provider -key when changing backend/account ownership. - -The account must allow the configured lease (two hours minimum). This leaves -room for Core's existing one-hour disconnect grace. Core renews the original -running sandbox; no auto-pause, auto-resume, recreation, pool or migration is -implemented. Provider expiry destroys volatile workspace/history; Core reports -failure and must not fabricate a recovered Session or replay execution. - -## Initialization and security boundary - -Core persists its allocation and dedicated credential hash before Create. E2B -metadata carries only installation, tenant, Environment, allocation, Session and -device identifiers. The account key stays in Core. A private root-owned input -injects the existing daemon auth profile, binds the workspace at `/workspace`, -then launches the non-root daemon with the image's explicit native profile. -Model credentials arrive through the existing authenticated execution contract. -Neither credential belongs in template environment, metadata, command arguments, -images or logs. - -E2B clears `/run` at boot and envd commands do not inherit template environment. -Initialization uses `/root/.parsar/e2b` and the root-owned image environment file. -E2B template finalization makes `/usr/local` writable and creates a passwordless -privileged `user` account. The protected `/opt/parsar-e2b/init.py` restores -root-owned executable paths (including injected envd/boot files) and locks that -unused account before starting daemon. These are required corrections to the -[provider's finalization](https://github.com/e2b-dev/runtime/blob/fad70f393e800cee0278669a63976c3aaa00871b/packages/orchestrator/pkg/template/build/phases/finalize/configure.sh), -not changes to the native harness. -Verify actual write and account-transition denial on every qualified template. -Its final atomic receipt distinguishes completed bootstrap from merely running -compute. On uncertain creation/initialization, Core observes the retained exact -allocation or reclaims it; it never retries startup or rotates its credential. -Inspection and cleanup recheck exact metadata ownership, including after restart. -A command timeout/transport failure is an unconfirmed effect, requiring cleanup -before reuse. Cancellation of a Provider request alone does not prove process exit. - -Native sandboxing remains mandatory inside the VM. Qualify actual tool reads, -credential/history isolation, process namespaces, privilege denial, unauthenticated -envd denial, both network policies and exact Core binding with each real harness. -A readable **inner** PID 1 environment is not itself access to the outer daemon; -verify namespace identity and actual sensitive-value/file access. Template builds -and SDK deserialization alone do not qualify deployment. - -## Acceptance scope - -Use a separate execution database, the fixed official OpenAI SDK plus raw HTTP, -real E2B instances and real model APIs. Cover all five Provider operations, actual -native execution, Files upload/list, immutable Artifacts, tenant/auth isolation, -cancellation with stopped effects, daemon/Core reconnect and exact-history recovery -without automatic replay. Keep failure and cleanup evidence. Mock tests do not -substitute for these checks. This deployment does not claim full official protocol -compatibility or add user-managed enrollment, new protocol resources or HA. - -`services/agents-api/tests/official_e2b_v1.py` runs this acceptance against the -packaged `bin/agents-api`, `bin/agents-api-migrate` and `upstream.json`. Use the -fixed OpenAI SDK from `contracts/agents-api/upstream.json`, plus `e2b` from this -directory's requirements. Pass a private JSON file with these operator inputs: - -```json -{ - "engine": "codex", - "model": "YOUR_REAL_MODEL", - "proof_root": "/absolute/private/proofs", - "package": "/absolute/agents-api-package", - "e2b_key_file": "/absolute/private/e2b.key", - "model_key_file": "/absolute/private/model.key", - "database_file": "/absolute/private/dedicated-database.url", - "options_file": "/absolute/private/execution-options.json", - "port": 19341, - "core_public_url": "https://acceptance-core.example.com", - "template": "TEMPLATE_ID:BUILD_UUID", - "native_history_root": "/home/runtime/.parsar/parsar-daemon/agent-sessions", - "psql_command": ["psql", "--dbname=YOUR_PRIVATE_TEST_DATABASE"] -} +If Create's response was lost before its ID was saved, discover candidates using +`Sandbox.list(query=SandboxQuery(metadata={'parsar_launch_id': launch_id}), +api_key=api_key)`, importing `SandboxQuery` from `e2b`. Consume pages while +`paginator.has_next` via `paginator.next_items()`. Verify both metadata fields +against the private record, retain every matching provider ID, and explicitly +inspect or destroy those allocations. An empty lookup is not permission to retry +an uncertain Create. Do not select an arbitrary candidate or rotate its identity. + +An uncertain startup result requires inspection of the same VM or explicit +cleanup. Root-only `/root/.parsar/e2b/launch.json` records the startup claim; +`ready.json` records only successful process handoff (`daemon_started`), even if +the daemon subsequently exits. Neither proves enrollment, native readiness or a +successful Turn. Check public Core Environment status and the private daemon log +at `/home/runtime/.parsar/parsar-daemon/default/daemon.log`. The daemon's separate +`/home/runtime/.parsar/parsar-daemon/environment.json` records the verified +Environment/Session binding. Keep these records and native history on failure. +Do not rerun `init.py`; it refuses any claimed attempt, including interrupted ones. +The SDK's `Sandbox.connect` can resume paused sandboxes, so it is not used as an +automatic recovery/inspection step here. VM expiry destroys volatile history; +never claim a replacement VM recovered the original Session. + +## Startup and security boundary + +The protected image initializer uses the image's explicit environment, restores +E2B-finalized executable/service permissions, locks the unused privileged `user` +account, and binds `/workspace`. It writes the executor key to a mode-`0600` file +inside the protected daemon directory, then starts the existing daemon as UID +1000 with that file path. The input is removed before startup. No bearer enters +the daemon's argv or inherited environment. Enrollment and immutable local binding +remain daemon responsibilities; startup does not invent device/Session IDs. + +E2B clears `/run` at boot, so startup records live under `/root/.parsar/e2b`. +Native sandboxing remains mandatory. Each actual template must verify private +credential/history isolation, protected binary/config ownership, privilege denial +and stopped descendant effects, rather than infer safety from file modes alone. +The one-shot receipt cannot be reused to replace the daemon or overwrite history. + +## Verification scope + +Run the focused local tests with the pinned SDK environment: + +```sh +python -m unittest discover -s services/agents-api/deploy/e2b -p '*_test.py' -v ``` -Route the public HTTPS/WebSocket endpoint to the test port. The fixture starts -and crashes its own Core and Runtime, creates billable sandboxes, and deletes -its Sessions and cloud allocations in cleanup. Use a dedicated database and -proof directory. `psql_command` must access that same database and accept `-At -c`; -do not put passwords in its arguments. Set the engine, history root and model -options for each qualified native profile. Failures retain redacted evidence; -direct provider cleanup is reported as failed Core cleanup, not acceptance. - -For initialized-environment regression, enable `verify_environment_templates`, -`verify_initial_files` and `verify_environment_setup` in the private test config. -Add `verify_system_packages` to exercise real apt packages, compilation/linking, -package/setup composition, native tool visibility and the finalized seed's hash -and ownership. This reuses the same public execution and recovery checks. +These are controlled startup-contract tests: input binding, protected key output, +unchanged URL, no secret in argv/environment/record, one-shot claim, and retained +provider ID on unknown outcomes. They do not create billable resources or qualify +E2B security, enrollment or model execution. The [qualification record](../../../../contracts/agents-api/user-managed-runtime-v1.md) +identifies actual three-harness deployment results and their verification limits, +including shared Core credential lifecycle checks and explicit application-owned +cleanup. `tests/official_user_runtime.py` supplies the shared +public execution checks. The former Core-managed `official_e2b_v1.py` fixture is +retired; its original source and evidence remain in Git history. diff --git a/services/agents-api/deploy/e2b/init.py b/services/agents-api/deploy/e2b/init.py index 550fe2a78..11e249ed6 100644 --- a/services/agents-api/deploy/e2b/init.py +++ b/services/agents-api/deploy/e2b/init.py @@ -1,69 +1,127 @@ #!/usr/bin/env python3 -"""One-shot trusted bootstrap; never a model/tool execution service.""" +"""One-shot user-owned Runtime startup; never an enrollment or execution service.""" import json import os from pathlib import Path import subprocess +from uuid import UUID +from urllib.parse import urlsplit -root = Path('/root/.parsar/e2b') -root.mkdir(mode=0o700, parents=True, exist_ok=True) -root.chmod(0o700) -source = root / 'bootstrap.json' -receipt = root / 'ready.json' -if receipt.exists(): - raise RuntimeError('Runtime initialization cannot be replayed') -bootstrap = json.loads(source.read_text()) -# E2B finalization makes /usr/local world-writable after template commands. -# Restore trusted executable ownership before launching the unprivileged Runtime. -subprocess.run(['chown', '-R', 'root:root', '/usr/local'], check=True) -subprocess.run(['chmod', '-R', 'go-w', '/usr/local'], check=True) -os.chmod('/usr/local/bin/agents-api-tool-root', 0o555) -# The provider also injects these root service/boot files with mode 0777. -for protected in ['/usr/bin/envd', '/etc/inittab', '/etc/init.d/rcS']: - # Some cloud images omit rcS after boot; no absent startup file needs access. - if protected == '/etc/init.d/rcS' and not Path(protected).exists(): - continue - os.chown(protected, 0, 0) - os.chmod(protected, 0o755) -# E2B also provisions an unused passwordless sudo account. Only root bootstrap -# and the explicit runtime account are used by this deployment. -subprocess.run(['usermod', '--lock', '--shell', '/usr/sbin/nologin', 'user'], check=True) -environment = json.loads(Path('/etc/parsar-runtime-env.json').read_text()) -environment.update( - PATH='/usr/local/bin:/usr/bin:/bin', - PARSAR_RUNTIME_ENVIRONMENT_ID=bootstrap['EnvironmentID'], - PARSAR_RUNTIME_SESSION_ID=bootstrap['session_id'], - PARSAR_RUNTIME_NETWORK_ACCESS=bootstrap['network_access'] or 'enabled', -) -subprocess.run(['mount', '--bind', '/environment/workspace', '/workspace'], check=True) -profile = Path('/home/runtime/.parsar/parsar-daemon/default') -profile.mkdir(mode=0o700, parents=True, exist_ok=True) -for directory in [Path('/home/runtime'), Path('/home/runtime/.parsar'), profile.parent, profile, - Path('/environment/workspace'), Path('/environment/staging'), - Path('/environment/initialization'), Path('/environment/packages')]: - os.chown(directory, 1000, 1000) - directory.chmod(0o700) -auth = profile / 'auth.json' -with auth.open('x') as stream: - json.dump({key: bootstrap[key] for key in ['server_url', 'runtime_id', 'runner_credential']}, stream) -auth.chmod(0o600) -os.chown(auth, 1000, 1000) -source.unlink() -log = Path('/home/runtime/.parsar/parsar-daemon/default/daemon.log') -with log.open('xb') as stream: - os.fchmod(stream.fileno(), 0o600) - os.fchown(stream.fileno(), 1000, 1000) - subprocess.Popen(['/usr/local/bin/parsar-daemon', 'connect', '--profile', 'default'], - cwd='/environment/workspace', env=environment, user=1000, group=1000, - extra_groups=[], start_new_session=True, stdin=subprocess.DEVNULL, - stdout=stream, stderr=subprocess.STDOUT, umask=0o077) -# Last mutating step. A lost response may observe this receipt but never rerun -# credential injection or daemon startup. A connected daemon is checked by Core. -temporary = root / 'ready.tmp' -with temporary.open('x') as stream: - os.fchmod(stream.fileno(), 0o600) - json.dump({key: bootstrap[key] for key in - ['TenantID', 'EnvironmentID', 'AllocationID', 'session_id', 'runtime_id']}, stream) - stream.flush() - os.fsync(stream.fileno()) -os.replace(temporary, receipt) +ROOT = Path('/root/.parsar/e2b') +PROFILE = Path('/home/runtime/.parsar/parsar-daemon/default') +IMAGE_ENV = Path('/etc/parsar-runtime-env.json') + + +def launch_identity(payload): + """Reject malformed startup input without including confidential values in errors.""" + if not isinstance(payload, dict) or set(payload) != { + 'launch_id', 'environment_id', 'remote_url', 'executor_key'}: + raise ValueError('Invalid Runtime startup fields') + try: + for field in ['launch_id', 'environment_id']: + value = payload[field] + if not isinstance(value, str) or str(UUID(value)) != value or UUID(value).int == 0: + raise ValueError() + key = payload['executor_key'] + if (not isinstance(key, dict) or set(key) - {'key_id', 'executor_token', 'environment_id'} + or str(UUID(key['key_id'])) != key['key_id'] or UUID(key['key_id']).int == 0 + or not isinstance(key['executor_token'], str) or not key['executor_token'] + or any(character.isspace() or character == '\x00' for character in key['executor_token']) + or key.get('environment_id') not in (None, '', payload['environment_id']) + or len(json.dumps(key).encode()) > 16384): + raise ValueError() + # Keep credentials out of argv/receipts; the daemon owns enrollment validation. + remote = payload['remote_url'] + address = urlsplit(remote) + if (not isinstance(remote, str) or address.scheme not in ('ws', 'wss') + or not address.hostname or address.username is not None + or address.query or address.fragment or '?' in remote or '#' in remote + or address.path != '/api/v1/agent-daemon/ws' or remote.strip() != remote + or len(json.dumps(payload).encode()) > 32768): + raise ValueError() + except (KeyError, ValueError, TypeError, AttributeError): + raise ValueError('Invalid Runtime startup identity or credential') from None + return {field: payload[field] for field in ['launch_id', 'environment_id', 'remote_url']} + + +def sync_directory(path): + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_private(path, value, owner=None): + with path.open('x') as stream: + os.fchmod(stream.fileno(), 0o600) + if owner is not None: + os.fchown(stream.fileno(), owner, owner) + json.dump(value, stream) + stream.flush() + os.fsync(stream.fileno()) + sync_directory(path.parent) + + +def initialize(): + ROOT.mkdir(mode=0o700, parents=True, exist_ok=True) + ROOT.chmod(0o700) + source = ROOT / 'bootstrap.json' + receipt = ROOT / 'ready.json' + if receipt.exists() or (ROOT / 'launch.json').exists(): + raise RuntimeError('Runtime startup cannot be replayed; inspect or destroy this sandbox') + if source.stat().st_size > 32768: + raise ValueError('Runtime startup input too large') + payload = json.loads(source.read_text()) + identity = launch_identity(payload) + # Claim before any side effect. An interrupted attempt must never start twice. + write_private(ROOT / 'launch.json', identity) + # E2B finalization makes /usr/local world-writable after template commands. + subprocess.run(['chown', '-R', 'root:root', '/usr/local'], check=True) + subprocess.run(['chmod', '-R', 'go-w', '/usr/local'], check=True) + os.chmod('/usr/local/bin/agents-api-tool-root', 0o555) + for protected in ['/usr/bin/envd', '/etc/inittab', '/etc/init.d/rcS']: + if protected == '/etc/init.d/rcS' and not Path(protected).exists(): + continue + os.chown(protected, 0, 0) + os.chmod(protected, 0o755) + # Disable E2B's unused passwordless sudo account before unprivileged startup. + subprocess.run(['usermod', '--lock', '--shell', '/usr/sbin/nologin', 'user'], check=True) + environment = json.loads(IMAGE_ENV.read_text()) + if (environment.get('PARSAR_HOME') != '/home/runtime/.parsar' + or environment.get('PARSAR_RUNTIME_WORKSPACE') != '/environment/workspace' + or any(key in environment for key in ['PARSAR_RUNTIME_ENVIRONMENT_ID', + 'PARSAR_RUNTIME_SESSION_ID'])): + raise ValueError('Image must contain an unbound packaged Runtime profile') + environment['PATH'] = '/usr/local/bin:/usr/bin:/bin' + subprocess.run(['mount', '--bind', '/environment/workspace', '/workspace'], check=True) + PROFILE.mkdir(mode=0o700, parents=True, exist_ok=True) + for directory in [Path('/home/runtime'), Path('/home/runtime/.parsar'), PROFILE.parent, PROFILE, + Path('/environment/workspace'), Path('/environment/staging'), + Path('/environment/initialization'), Path('/environment/packages')]: + os.chown(directory, 1000, 1000) + directory.chmod(0o700) + credential = PROFILE.parent / 'executor-key.json' + write_private(credential, payload['executor_key'], owner=1000) + source.unlink() + with (PROFILE / 'daemon.log').open('xb') as stream: + os.fchmod(stream.fileno(), 0o600) + os.fchown(stream.fileno(), 1000, 1000) + child = subprocess.Popen( + ['/usr/local/bin/parsar-daemon', 'connect', '--profile', 'default', + '--remote', payload['remote_url'], '--environment-id', payload['environment_id'], + '--credential-file', str(credential)], + cwd='/environment/workspace', env=environment, user=1000, group=1000, + extra_groups=[], start_new_session=True, stdin=subprocess.DEVNULL, + stdout=stream, stderr=subprocess.STDOUT, umask=0o077) + # This acknowledges process handoff only. Core owns enrollment and readiness. + write_private(ROOT / 'ready.tmp', dict(identity, status='daemon_started', daemon_pid=child.pid)) + os.replace(ROOT / 'ready.tmp', receipt) + sync_directory(ROOT) + + +if __name__ == '__main__': + try: + initialize() + except Exception: + raise SystemExit('Runtime startup failed; inspect the retained launch record and sandbox') from None diff --git a/services/agents-api/deploy/e2b/init_test.py b/services/agents-api/deploy/e2b/init_test.py new file mode 100644 index 000000000..702bcfb1e --- /dev/null +++ b/services/agents-api/deploy/e2b/init_test.py @@ -0,0 +1,122 @@ +"""Controlled startup contract tests; these do not qualify an E2B Runtime.""" +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +import init +import launch + + +PAYLOAD = { + 'launch_id': 'bfe27bc4-dcd7-4aa3-9196-94f7617c9d6b', + 'environment_id': 'b03296db-a32d-49e3-afc3-0b2a3c00f73b', + 'remote_url': 'wss://core.example.com/api/v1/agent-daemon/ws', + 'executor_key': {'key_id': '96e3ba3e-6eb7-4f1b-b65b-1c836c419297', + 'executor_token': 'test-private-key'}, +} +TEMPLATE = 'qualified:' + PAYLOAD['launch_id'] + + +class StartupTest(unittest.TestCase): + def test_identity_rejects_confused_binding_and_secret_urls(self): + for field, value in [('environment_id', 'not-a-uuid'), + ('remote_url', PAYLOAD['remote_url'] + '?token=private'), + ('remote_url', 'wss://secret@core.example.com/api/v1/agent-daemon/ws'), + ('executor_key', dict(PAYLOAD['executor_key'], environment_id=PAYLOAD['launch_id']))]: + with self.subTest(field=field), self.assertRaises(ValueError): + init.launch_identity(dict(PAYLOAD, **{field: value})) + self.assertEqual(init.launch_identity(PAYLOAD)['remote_url'], PAYLOAD['remote_url']) + + def test_launch_handoff_is_private_and_cannot_replay(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / 'root' + root.mkdir() + profile = Path(temporary) / 'private/default' + environment_file = Path(temporary) / 'image.json' + environment_file.write_text(json.dumps({'HOME': '/home/runtime', + 'PARSAR_HOME': '/home/runtime/.parsar', 'PARSAR_RUNTIME_WORKSPACE': '/environment/workspace'})) + (root / 'bootstrap.json').write_text(json.dumps(PAYLOAD)) + real_chmod = Path.chmod + + def chmod(path, mode): + if str(path).startswith(temporary): + real_chmod(path, mode) + + with patch.object(init, 'ROOT', root), patch.object(init, 'PROFILE', profile), \ + patch.object(init, 'IMAGE_ENV', environment_file), \ + patch.object(init.os, 'chown'), patch.object(init.os, 'fchown'), \ + patch.object(init.os, 'chmod'), patch.object(Path, 'chmod', chmod), \ + patch.object(init.subprocess, 'run') as run, \ + patch.object(init.subprocess, 'Popen', return_value=Mock(pid=321)) as popen: + init.initialize() + argv = popen.call_args.args[0] + options = popen.call_args.kwargs + self.assertEqual(argv[argv.index('--remote') + 1], PAYLOAD['remote_url']) + self.assertNotIn('test-private-key', repr(popen.call_args)) + self.assertNotIn('PARSAR_RUNTIME_SESSION_ID', options['env']) + self.assertEqual((options['user'], options['group'], options['extra_groups']), (1000, 1000, [])) + self.assertEqual(options['umask'], 0o077) + key = profile.parent / 'executor-key.json' + self.assertEqual(json.loads(key.read_text()), PAYLOAD['executor_key']) + self.assertEqual(key.stat().st_mode & 0o777, 0o600) + self.assertFalse((profile / 'auth.json').exists()) + self.assertFalse((root / 'bootstrap.json').exists()) + receipt = json.loads((root / 'ready.json').read_text()) + self.assertEqual(receipt['status'], 'daemon_started') + self.assertNotIn('session_id', receipt) + self.assertNotIn('test-private-key', (root / 'launch.json').read_text()) + run.reset_mock() + with self.assertRaises(RuntimeError): + init.initialize() + run.assert_not_called() + popen.assert_called_once() + + def test_failed_initialization_retains_claim_without_ready(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'bootstrap.json').write_text(json.dumps(PAYLOAD)) + with patch.object(init, 'ROOT', root), patch.object(init.subprocess, 'run', side_effect=RuntimeError): + with self.assertRaises(RuntimeError): + init.initialize() + self.assertTrue((root / 'launch.json').exists()) + self.assertFalse((root / 'ready.json').exists()) + with self.assertRaisesRegex(RuntimeError, 'cannot be replayed'): + init.initialize() + + def test_sdk_startup_retains_id_and_never_retries_unknown_outcomes(self): + for fail in ['create', 'start', None]: + with self.subTest(fail=fail), tempfile.TemporaryDirectory() as temporary: + record = Path(temporary) / 'launch.json' + sandbox = Mock(sandbox_id='owned-id') + sandbox.files.read.return_value = json.dumps(dict(init.launch_identity(PAYLOAD), + status='daemon_started', daemon_pid=123)) + if fail == 'start': + sandbox.commands.run.side_effect = RuntimeError('test-private-key') + with patch.object(launch.Sandbox, 'create', return_value=sandbox) as create: + if fail == 'create': + create.side_effect = RuntimeError('test-private-key') + if fail: + with self.assertRaisesRegex(RuntimeError, 'outcome uncertain') as raised: + launch.launch(copy.deepcopy(PAYLOAD), TEMPLATE, 'e2b-private-key', record) + self.assertNotIn('test-private-key', str(raised.exception)) + else: + result = launch.launch(PAYLOAD, TEMPLATE, 'e2b-private-key', record) + self.assertEqual(result['status'], 'daemon_started') + persisted = json.loads(record.read_text()) + self.assertNotIn('private-key', record.read_text()) + self.assertEqual(record.stat().st_mode & 0o777, 0o600) + self.assertEqual(persisted.get('sandbox_id'), None if fail == 'create' else 'owned-id') + self.assertEqual(create.call_args.kwargs['lifecycle'], + {'on_timeout': 'kill', 'auto_resume': False}) + self.assertNotIn('private-key', repr(create.call_args.kwargs['metadata'])) + with self.assertRaises(FileExistsError): + launch.launch(PAYLOAD, TEMPLATE, 'e2b-private-key', record) + create.assert_called_once() + sandbox.kill.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/services/agents-api/deploy/e2b/launch.py b/services/agents-api/deploy/e2b/launch.py new file mode 100644 index 000000000..925edca2e --- /dev/null +++ b/services/agents-api/deploy/e2b/launch.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Application-owned E2B startup example using the maintained SDK.""" +import argparse +import json +import os +from pathlib import Path +from uuid import UUID + +from e2b import Sandbox +from init import launch_identity, sync_directory, write_private + + +def launch(payload, template, api_key, record_path, timeout=7200): + identity = launch_identity(payload) + try: + template_id, build_id = template.split(':') + if not template_id or str(UUID(build_id)) != build_id or UUID(build_id).int == 0 or timeout <= 0: + raise ValueError() + except (ValueError, AttributeError): + raise ValueError('Use a pinned templateID:build_UUID and a positive lease') from None + record_path = Path(record_path) + if not record_path.is_absolute(): + raise ValueError('Use an absolute private launch record path') + record_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + record = dict(identity, template=template, status='create_pending') + # Exclusive creation prevents accidental retries, including unknown Create outcomes. + write_private(record_path, record) + + def save(status, **fields): + record.update(status=status, **fields) + temporary = record_path.with_name(record_path.name + '.tmp') + write_private(temporary, record) + os.replace(temporary, record_path) + sync_directory(record_path.parent) + + try: + sandbox = Sandbox.create( + template=template, timeout=timeout, api_key=api_key, + metadata={'parsar_launch_id': payload['launch_id'], + 'parsar_environment_id': payload['environment_id']}, + lifecycle={'on_timeout': 'kill', 'auto_resume': False}) + # Retain the actual provider ID before uploading credentials or starting anything. + save('created', sandbox_id=sandbox.sandbox_id) + save('startup_pending') + sandbox.files.write('/root/.parsar/e2b/bootstrap.json', json.dumps(payload), + user='root', request_timeout=30) + sandbox.commands.run('/usr/bin/python3 /opt/parsar-e2b/init.py', user='root', timeout=60) + receipt = json.loads(sandbox.files.read('/root/.parsar/e2b/ready.json', + user='root', request_timeout=30)) + if (any(receipt.get(key) != value for key, value in identity.items()) + or receipt.get('status') != 'daemon_started' + or type(receipt.get('daemon_pid')) is not int or receipt['daemon_pid'] <= 0): + raise ValueError('Unexpected startup receipt') + save('daemon_started', receipt=receipt) + return record + except Exception: + # SDK exceptions can contain request/command details. Keep the durable record, + # do not log credentials, repeat Create/start, or implicitly delete evidence. + raise RuntimeError('Launch outcome uncertain; inspect the private record and owned sandbox') from None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--remote-url', required=True) + parser.add_argument('--environment-id', required=True) + parser.add_argument('--launch-id', required=True, help='Application-generated UUID, retained before Create') + parser.add_argument('--template', required=True, help='Immutable templateID:build_UUID') + parser.add_argument('--executor-key-file', required=True, type=Path) + parser.add_argument('--api-key-file', required=True, type=Path) + parser.add_argument('--record', required=True, type=Path) + parser.add_argument('--timeout', type=int, default=7200, help='User-owned lease in seconds') + args = parser.parse_args() + try: + result = launch( + {'launch_id': args.launch_id, 'environment_id': args.environment_id, + 'remote_url': args.remote_url, + 'executor_key': json.loads(args.executor_key_file.read_text())}, + args.template, args.api_key_file.read_text().strip(), args.record, args.timeout) + except Exception: + parser.exit(1, 'Startup not confirmed. Inspect your launch record; do not rerun or replace the sandbox.\n') + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/agents-api/internal/api/environment_creation_test.go index 58d7204b3..cbd45e54f 100644 --- a/services/agents-api/internal/api/environment_creation_test.go +++ b/services/agents-api/internal/api/environment_creation_test.go @@ -77,7 +77,7 @@ func TestSelfHostedEmptyCreationAndStream(t *testing.T) { defer server.Close() ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/remote/workspace"%s},"stream":%t%s}`, capability, stream, input) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"%s},"stream":%t%s}`, capability, stream, input) request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(body)) if err != nil { t.Fatal(err) @@ -123,7 +123,7 @@ func TestSelfHostedEmptyCreationAndStream(t *testing.T) { if persisted.Engine != "codex" || persisted.Creator.Kind != "service_account" || persisted.Creator.ID != "test-runner" || persisted.IdempotencyKey != "empty-environment" { t.Fatal("creation lost engine or authenticated identity", persisted) } - if session.Environment.ID != created.Environment.ID || session.Environment.RemoteURL != environmentOrigin || session.Environment.WorkspaceDirectory != "/remote/workspace" || session.Environment.CapabilityDirectories == nil || *session.Environment.CapabilityDirectories == nil || len(*session.Environment.CapabilityDirectories) != 0 { + if session.Environment.ID != created.Environment.ID || session.Environment.RemoteURL != environmentOrigin || session.Environment.WorkspaceDirectory != "/workspace" || session.Environment.CapabilityDirectories == nil || *session.Environment.CapabilityDirectories == nil || len(*session.Environment.CapabilityDirectories) != 0 { t.Fatal("creation did not use the owned Environment and configured origin", session.Environment) } value := string(created.Environment.Configuration) @@ -140,15 +140,15 @@ func TestSelfHostedEmptyCreationAndStream(t *testing.T) { } func TestSelfHostedCreationRejectsBeforePersistence(t *testing.T) { - validEnvironment := `{"type":"self_hosted","workspace_directory":"/remote/workspace"}` + validEnvironment := `{"type":"self_hosted","workspace_directory":"/workspace"}` for _, tc := range []struct { name, environment, agentFields, input, engine string }{ {name: "missing environment", environment: ""}, {name: "null environment", environment: "null"}, {name: "array environment", environment: "[]"}, - {name: "missing type", environment: `{"workspace_directory":"/remote/workspace"}`}, - {name: "null type", environment: `{"type":null,"workspace_directory":"/remote/workspace"}`}, + {name: "missing type", environment: `{"workspace_directory":"/workspace"}`}, + {name: "null type", environment: `{"type":null,"workspace_directory":"/workspace"}`}, {name: "missing workspace", environment: `{"type":"self_hosted"}`}, {name: "null workspace", environment: `{"type":"self_hosted","workspace_directory":null}`}, {name: "numeric workspace", environment: `{"type":"self_hosted","workspace_directory":1}`}, @@ -158,15 +158,15 @@ func TestSelfHostedCreationRejectsBeforePersistence(t *testing.T) { {name: "newline workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\n"}`}, {name: "carriage return workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\r"}`}, {name: "backslash workspace placement", environment: `{"type":"self_hosted","workspace_directory":"/remote/\\"}`}, - {name: "capabilities", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":["/remote/skills"]}`}, - {name: "null capability entry", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":[null]}`}, - {name: "scalar capabilities", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":"/remote/skills"}`}, - {name: "output field", environment: `{"type":"self_hosted","workspace_directory":"/remote/workspace","remote_url":"https://forged.example"}`}, + {name: "capabilities", environment: `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":["/remote/skills"]}`}, + {name: "null capability entry", environment: `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[null]}`}, + {name: "scalar capabilities", environment: `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":"/remote/skills"}`}, + {name: "output field", environment: `{"type":"self_hosted","workspace_directory":"/workspace","remote_url":"https://forged.example"}`}, {name: "none extra field", environment: `{"type":"none","workspace_directory":null}`}, {name: "initial image", environment: validEnvironment, input: `,"input":[{"role":"user","content":[{"type":"input_image","image_url":"https://example.com/image.png"}]}]`}, {name: "initial assistant message", environment: validEnvironment, input: `,"input":[{"role":"assistant","content":[{"type":"input_text","text":"start"}]}]`}, {name: "deferred functions", environment: validEnvironment, agentFields: `,"tools":[{"type":"function","name":"lookup","description":"Find a value","parameters":{"type":"object"},"defer_loading":true}]`}, - {name: "Claude SDK placement", environment: validEnvironment, engine: "claude_sdk"}, + {name: "unregistered harness placement", environment: validEnvironment, engine: "unregistered"}, {name: "Claude Code placement", environment: validEnvironment, engine: "claude_code"}, } { for _, stream := range []bool{false, true} { @@ -198,7 +198,7 @@ func TestSelfHostedCreationRequiresOperatorExecution(t *testing.T) { for _, options := range [][]Option{nil, {WithExecution(&inputRecorder{})}, {WithEnvironmentRemoteURL(environmentOrigin)}} { for _, stream := range []bool{false, true} { handler, fixture := environmentCreationHandler(t, "codex", options...) - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/remote/workspace"},"stream":%t}`, stream) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":%t}`, stream) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer key") request.Header.Set("OpenAI-Beta", "agents=v1") diff --git a/services/agents-api/internal/api/environment_files_create_test.go b/services/agents-api/internal/api/environment_files_create_test.go index f0c8cec11..56bbcb61d 100644 --- a/services/agents-api/internal/api/environment_files_create_test.go +++ b/services/agents-api/internal/api/environment_files_create_test.go @@ -128,8 +128,8 @@ func TestEnvironmentFileCreateAuthorityAndUncertainResults(t *testing.T) { t.Fatal("wrong byte count reported success", w.Code) } f.environment.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`) - f.writes = 0 - if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 503 || f.writes != 0 { - t.Fatal("unsupported placement admitted", w.Code) + f.writes, f.wrongSize = 0, false + if w := requestCreateEnvironmentFile(h, f.environment.ID, body, "files-key"); w.Code != 200 || f.writes != 1 { + t.Fatal("enrolled local workspace write rejected", w.Code) } } diff --git a/services/agents-api/internal/api/environment_files_query.go b/services/agents-api/internal/api/environment_files_query.go index 4cd8da3f0..a4b82cd1f 100644 --- a/services/agents-api/internal/api/environment_files_query.go +++ b/services/agents-api/internal/api/environment_files_query.go @@ -46,13 +46,10 @@ func readEnvironmentFileQuery(w http.ResponseWriter, r *http.Request, environmen } root := "/workspace" if !execution.LocalWorkspaceConfiguration(environment.Configuration) { - configuration, err := decodeSessionEnvironment(environment.Configuration) - if err != nil || configuration.Type != "self_hosted" || len(configuration.CapabilityDirectories) != 0 || !validEnvironmentFilePath(configuration.WorkspaceDirectory) { - writeStoreError(w, r, execution.ErrExecutionUnavailable) - return options, false - } - root = path.Clean(configuration.WorkspaceDirectory) + writeStoreError(w, r, execution.ErrExecutionUnavailable) + return options, false } + directory := root if requested, exists := q["path"]; exists { if !validEnvironmentFilePath(requested[0]) { diff --git a/services/agents-api/internal/api/environment_files_test.go b/services/agents-api/internal/api/environment_files_test.go index 4bc23fbe1..e6a338f45 100644 --- a/services/agents-api/internal/api/environment_files_test.go +++ b/services/agents-api/internal/api/environment_files_test.go @@ -59,7 +59,7 @@ func environmentFilesHandler(t *testing.T, enabled bool) (http.Handler, *environ t.Helper() f := &environmentFilesFixture{ environment: store.Environment{ID: uuid.NewString(), TenantID: uuid.NewString(), SessionID: uuid.NewString(), Status: "connected", - Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/private/workspace"}`)}, + Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, result: proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}}, } keys := []APIKey{} @@ -126,7 +126,7 @@ func TestEnvironmentFilesOrderingPaginationAndProjection(t *testing.T) { environmentFileEntry("a.txt", 14), environmentFileEntry("z.txt", 5), environmentFileEntry("A.txt", 0), environmentFileEntry("a-b.txt", 6), {Name: "directory", Kind: "directory"}, {Name: "symlink", Kind: "symlink"}, {Name: "socket", Kind: "other"}, } - q := url.Values{"path": {"/private/workspace/sub/.//"}, "limit": {"2"}} + q := url.Values{"path": {"/workspace/sub/.//"}, "limit": {"2"}} if order != "" { q.Set("order", order) } @@ -151,7 +151,7 @@ func TestEnvironmentFilesOrderingPaginationAndProjection(t *testing.T) { t.Fatal("unexpected continuation") } } - want := []string{"/private/workspace/sub/z.txt", "/private/workspace/sub/a.txt", "/private/workspace/sub/a-b.txt", "/private/workspace/sub/A.txt"} + want := []string{"/workspace/sub/z.txt", "/workspace/sub/a.txt", "/workspace/sub/a-b.txt", "/workspace/sub/A.txt"} if order == "asc" { want = []string{want[3], want[2], want[1], want[0]} } @@ -172,7 +172,7 @@ func TestEnvironmentFilesEmptyRootDefaultsAndSharedAccess(t *testing.T) { f.result.Entries = append(f.result.Entries, environmentFileEntry(string(rune('A'+index)), int64(index))) } page = decodeEnvironmentFiles(t, requestEnvironmentFiles(h, f.environment.ID, "", "files-key")) - if len(page.Data) != 20 || page.Next == nil || page.Data[0].Path != "/private/workspace/U" { + if len(page.Data) != 20 || page.Next == nil || page.Data[0].Path != "/workspace/U" { t.Fatal("wrong local defaults", page) } q := url.Values{"page": {*page.Next}} @@ -201,7 +201,7 @@ func TestEnvironmentFilesAuthorizationPrecedesInspection(t *testing.T) { func TestEnvironmentFilesRejectsInvalidRequestsBeforeRead(t *testing.T) { for _, query := range []string{ - "limit=0", "limit=101", "limit=no", "limit=", "limit=1&limit=2", "order=ASC", "order=", "after=x", "unknown=x", "path=", "path=relative", "path=/private/workspace-sibling", "path=/private/workspace/../workspace", "path=/private/workspace/a/../../workspace", "path=/private/workspace/%00", "path=/private/workspace/%5C", "path=/private/workspace/%0A", "path=/private/workspace/%FF", "path=x&path=y", "path=" + strings.Repeat("a", 4097), "page=", "page=not-json", "page=" + strings.Repeat("a", 1025), "bad=%GG", + "limit=0", "limit=101", "limit=no", "limit=", "limit=1&limit=2", "order=ASC", "order=", "after=x", "unknown=x", "path=", "path=relative", "path=/workspace-sibling", "path=/workspace/../workspace", "path=/workspace/a/../../workspace", "path=/workspace/%00", "path=/workspace/%5C", "path=/workspace/%0A", "path=/workspace/%FF", "path=x&path=y", "path=" + strings.Repeat("a", 4097), "page=", "page=not-json", "page=" + strings.Repeat("a", 1025), "bad=%GG", } { t.Run(query[:min(len(query), 70)], func(t *testing.T) { h, f := environmentFilesHandler(t, true) diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/agents-api/internal/api/environment_input_test.go index 14e76ddfd..f54446388 100644 --- a/services/agents-api/internal/api/environment_input_test.go +++ b/services/agents-api/internal/api/environment_input_test.go @@ -64,7 +64,7 @@ func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T options := []Option{WithExecution(waiting)} environmentJSON := `{"type":"none"}` if environment == "self_hosted" { - environmentJSON = `{"type":"self_hosted","workspace_directory":"/remote/workspace"}` + environmentJSON = `{"type":"self_hosted","workspace_directory":"/workspace"}` options = append(options, WithEnvironmentRemoteURL(environmentOrigin)) } handler, fixture := environmentCreationHandler(t, "codex", options...) diff --git a/services/agents-api/internal/api/harness_test.go b/services/agents-api/internal/api/harness_test.go index f561918e4..3798e28ac 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/agents-api/internal/api/harness_test.go @@ -26,7 +26,7 @@ func TestSessionHarnessAdmission(t *testing.T) { {"empty", `,"x_agents_core":{}`, "", `{"type":"none"}`, "", true, 400}, {"unknown nested", `,"x_agents_core":{"harness":"codex","model":"wrong"}`, "", `{"type":"none"}`, "", true, 400}, {"claude verbosity", `,"x_agents_core":{"harness":"claude_sdk"}`, `,"text":{"verbosity":"high"}`, `{"type":"none"}`, "", true, 400}, - {"mcode remote", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"}`, "", true, 400}, + {"mcode self-hosted requires executor configuration", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"}`, "", true, 503}, } { t.Run(tc.name, func(t *testing.T) { var options []Option diff --git a/services/agents-api/internal/api/session_environment_test.go b/services/agents-api/internal/api/session_environment_test.go index 03d854748..9ceb008ae 100644 --- a/services/agents-api/internal/api/session_environment_test.go +++ b/services/agents-api/internal/api/session_environment_test.go @@ -10,7 +10,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -const environmentOrigin = "https://executor.example" +const environmentOrigin = "wss://core.example/api/v1/agent-daemon/ws" func environmentSession() store.Session { return store.Session{ diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go index b5f09221c..810d0ebaa 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/agents-api/internal/api/session_response.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// WithEnvironmentRemoteURL uses the composition's validated executor origin for self-hosted requests and output. +// WithEnvironmentRemoteURL uses the composition's validated daemon executor URL for self-hosted requests and output. func WithEnvironmentRemoteURL(origin string) Option { return func(h *Handler) { h.executorURL = origin } } diff --git a/services/agents-api/internal/db/queries/devices.sql b/services/agents-api/internal/db/queries/devices.sql index 55da02c9f..698c5ecfe 100644 --- a/services/agents-api/internal/db/queries/devices.sql +++ b/services/agents-api/internal/db/queries/devices.sql @@ -6,11 +6,7 @@ VALUES ($1, $2, $3, $4) RETURNING id; SELECT id, name FROM devices WHERE tenant_id = $1 AND id = $2 AND revoked_at IS NULL; -- name: GetDeviceCredential :one -SELECT d.id, d.name, d.credential_hash FROM devices d -WHERE d.id = $1 AND d.revoked_at IS NULL AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE e.id = d.environment_id AND s.tenant_id = d.tenant_id AND s.deleted_at IS NULL -)); +SELECT id, name, credential_hash FROM runtime_device_authority WHERE id = $1; -- name: RevokeDevice :execrows UPDATE devices SET revoked_at = COALESCE(revoked_at, clock_timestamp()) @@ -18,10 +14,7 @@ WHERE tenant_id = $1 AND id = $2; -- name: TouchDevice :execrows UPDATE devices SET last_seen_at = clock_timestamp() -WHERE devices.id = $1 AND devices.revoked_at IS NULL AND (devices.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE e.id = devices.environment_id AND s.tenant_id = devices.tenant_id AND s.deleted_at IS NULL -)); +WHERE devices.id = $1 AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = devices.id); -- name: BindSessionDevice :one INSERT INTO session_devices (session_id, device_id) @@ -39,6 +32,7 @@ SELECT d.id, d.name, d.environment_id FROM session_devices b JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) AND (d.environment_id IS NULL OR EXISTS ( SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id )); @@ -50,6 +44,7 @@ FROM session_devices b JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) AND (d.environment_id IS NULL OR EXISTS ( SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id )); diff --git a/services/agents-api/internal/db/queries/runtime_enrollment.sql b/services/agents-api/internal/db/queries/runtime_enrollment.sql new file mode 100644 index 000000000..9fadd340c --- /dev/null +++ b/services/agents-api/internal/db/queries/runtime_enrollment.sql @@ -0,0 +1,36 @@ +-- name: AuthorizeRuntimeEnrollment :one +SELECT c.key_id, e.session_id, COALESCE(s.configuration->'environment'->>'workspace_directory', '')::text AS workspace_directory +FROM environment_executor_credentials c +JOIN environments e ON e.id = sqlc.arg(environment_id) +JOIN sessions s ON s.id = e.session_id +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +WHERE c.token_sha256 = sqlc.arg(token_sha256) AND c.revoked_at IS NULL + AND c.tenant_id = s.tenant_id AND s.tenant_id = sqlc.arg(tenant_id) + AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) + AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') + AND s.configuration->'environment'->>'type' = 'self_hosted' +FOR SHARE OF c; + +-- name: EnrollRuntimeDevice :one +INSERT INTO devices (id, tenant_id, name, environment_id, executor_key_id) +VALUES (sqlc.arg(id), sqlc.arg(tenant_id), 'User-managed Runtime', sqlc.arg(environment_id), sqlc.arg(executor_key_id)) +ON CONFLICT (environment_id) DO UPDATE SET name = devices.name +WHERE devices.executor_key_id = EXCLUDED.executor_key_id AND devices.revoked_at IS NULL +RETURNING id, name, environment_id; + +-- name: TouchAuthenticatedDevice :execrows +UPDATE devices SET last_seen_at = clock_timestamp() +WHERE devices.id = sqlc.arg(id) AND EXISTS ( + SELECT 1 FROM runtime_device_authority a + WHERE a.id = devices.id AND a.credential_hash = sqlc.arg(credential_hash) +); + +-- name: ListEnrolledRuntimeBindings :many +SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id, e.session_id +FROM devices d +JOIN environments e ON e.id = d.environment_id +JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +WHERE d.executor_key_id IS NOT NULL AND s.deleted_at IS NULL + AND e.status NOT IN ('failed', 'expired') +ORDER BY d.id; diff --git a/services/agents-api/internal/db/sqlc/devices.sql.go b/services/agents-api/internal/db/sqlc/devices.sql.go index 8b822474f..8cc805f5d 100644 --- a/services/agents-api/internal/db/sqlc/devices.sql.go +++ b/services/agents-api/internal/db/sqlc/devices.sql.go @@ -45,7 +45,7 @@ type CreateDeviceParams struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` Name string `json:"name"` - CredentialHash string `json:"credential_hash"` + CredentialHash pgtype.Text `json:"credential_hash"` } func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (pgtype.UUID, error) { @@ -82,11 +82,7 @@ func (q *Queries) GetDevice(ctx context.Context, arg GetDeviceParams) (GetDevice } const getDeviceCredential = `-- name: GetDeviceCredential :one -SELECT d.id, d.name, d.credential_hash FROM devices d -WHERE d.id = $1 AND d.revoked_at IS NULL AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE e.id = d.environment_id AND s.tenant_id = d.tenant_id AND s.deleted_at IS NULL -)) +SELECT id, name, credential_hash FROM runtime_device_authority WHERE id = $1 ` type GetDeviceCredentialRow struct { @@ -107,6 +103,7 @@ SELECT d.id, d.name, d.environment_id FROM session_devices b JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) AND (d.environment_id IS NULL OR EXISTS ( SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id )) @@ -137,6 +134,7 @@ FROM session_devices b JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) AND (d.environment_id IS NULL OR EXISTS ( SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id )) @@ -205,10 +203,7 @@ func (q *Queries) RevokeDevice(ctx context.Context, arg RevokeDeviceParams) (int const touchDevice = `-- name: TouchDevice :execrows UPDATE devices SET last_seen_at = clock_timestamp() -WHERE devices.id = $1 AND devices.revoked_at IS NULL AND (devices.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE e.id = devices.environment_id AND s.tenant_id = devices.tenant_id AND s.deleted_at IS NULL -)) +WHERE devices.id = $1 AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = devices.id) ` func (q *Queries) TouchDevice(ctx context.Context, id pgtype.UUID) (int64, error) { diff --git a/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go b/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go index 109216093..51c15c33b 100644 --- a/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go +++ b/services/agents-api/internal/db/sqlc/local_environment_devices.sql.go @@ -24,7 +24,7 @@ RETURNING id type CreateEnvironmentDeviceParams struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` - CredentialHash string `json:"credential_hash"` + CredentialHash pgtype.Text `json:"credential_hash"` TenantID pgtype.UUID `json:"tenant_id"` EnvironmentID pgtype.UUID `json:"environment_id"` } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 6480e227d..7b2405992 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -21,11 +21,12 @@ type Device struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` Name string `json:"name"` - CredentialHash string `json:"credential_hash"` + CredentialHash pgtype.Text `json:"credential_hash"` CreatedAt pgtype.Timestamptz `json:"created_at"` LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` RevokedAt pgtype.Timestamptz `json:"revoked_at"` EnvironmentID pgtype.UUID `json:"environment_id"` + ExecutorKeyID pgtype.UUID `json:"executor_key_id"` } type Environment struct { @@ -140,6 +141,14 @@ type RuntimeAllocation struct { Initialization string `json:"initialization"` } +type RuntimeDeviceAuthority struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + EnvironmentID pgtype.UUID `json:"environment_id"` + CredentialHash string `json:"credential_hash"` +} + type Session struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` diff --git a/services/agents-api/internal/db/sqlc/runtime_enrollment.sql.go b/services/agents-api/internal/db/sqlc/runtime_enrollment.sql.go new file mode 100644 index 000000000..5bc1b1d0c --- /dev/null +++ b/services/agents-api/internal/db/sqlc/runtime_enrollment.sql.go @@ -0,0 +1,142 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: runtime_enrollment.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const authorizeRuntimeEnrollment = `-- name: AuthorizeRuntimeEnrollment :one +SELECT c.key_id, e.session_id, COALESCE(s.configuration->'environment'->>'workspace_directory', '')::text AS workspace_directory +FROM environment_executor_credentials c +JOIN environments e ON e.id = $1 +JOIN sessions s ON s.id = e.session_id +JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id +WHERE c.token_sha256 = $2 AND c.revoked_at IS NULL + AND c.tenant_id = s.tenant_id AND s.tenant_id = $3 + AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) + AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') + AND s.configuration->'environment'->>'type' = 'self_hosted' +FOR SHARE OF c +` + +type AuthorizeRuntimeEnrollmentParams struct { + EnvironmentID pgtype.UUID `json:"environment_id"` + TokenSha256 string `json:"token_sha256"` + TenantID pgtype.UUID `json:"tenant_id"` +} + +type AuthorizeRuntimeEnrollmentRow struct { + KeyID pgtype.UUID `json:"key_id"` + SessionID pgtype.UUID `json:"session_id"` + WorkspaceDirectory string `json:"workspace_directory"` +} + +func (q *Queries) AuthorizeRuntimeEnrollment(ctx context.Context, arg AuthorizeRuntimeEnrollmentParams) (AuthorizeRuntimeEnrollmentRow, error) { + row := q.db.QueryRow(ctx, authorizeRuntimeEnrollment, arg.EnvironmentID, arg.TokenSha256, arg.TenantID) + var i AuthorizeRuntimeEnrollmentRow + err := row.Scan(&i.KeyID, &i.SessionID, &i.WorkspaceDirectory) + return i, err +} + +const enrollRuntimeDevice = `-- name: EnrollRuntimeDevice :one +INSERT INTO devices (id, tenant_id, name, environment_id, executor_key_id) +VALUES ($1, $2, 'User-managed Runtime', $3, $4) +ON CONFLICT (environment_id) DO UPDATE SET name = devices.name +WHERE devices.executor_key_id = EXCLUDED.executor_key_id AND devices.revoked_at IS NULL +RETURNING id, name, environment_id +` + +type EnrollRuntimeDeviceParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + ExecutorKeyID pgtype.UUID `json:"executor_key_id"` +} + +type EnrollRuntimeDeviceRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) EnrollRuntimeDevice(ctx context.Context, arg EnrollRuntimeDeviceParams) (EnrollRuntimeDeviceRow, error) { + row := q.db.QueryRow(ctx, enrollRuntimeDevice, + arg.ID, + arg.TenantID, + arg.EnvironmentID, + arg.ExecutorKeyID, + ) + var i EnrollRuntimeDeviceRow + err := row.Scan(&i.ID, &i.Name, &i.EnvironmentID) + return i, err +} + +const listEnrolledRuntimeBindings = `-- name: ListEnrolledRuntimeBindings :many +SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id, e.session_id +FROM devices d +JOIN environments e ON e.id = d.environment_id +JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +WHERE d.executor_key_id IS NOT NULL AND s.deleted_at IS NULL + AND e.status NOT IN ('failed', 'expired') +ORDER BY d.id +` + +type ListEnrolledRuntimeBindingsRow struct { + DeviceID pgtype.UUID `json:"device_id"` + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + SessionID pgtype.UUID `json:"session_id"` +} + +func (q *Queries) ListEnrolledRuntimeBindings(ctx context.Context) ([]ListEnrolledRuntimeBindingsRow, error) { + rows, err := q.db.Query(ctx, listEnrolledRuntimeBindings) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnrolledRuntimeBindingsRow{} + for rows.Next() { + var i ListEnrolledRuntimeBindingsRow + if err := rows.Scan( + &i.DeviceID, + &i.TenantID, + &i.EnvironmentID, + &i.SessionID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchAuthenticatedDevice = `-- name: TouchAuthenticatedDevice :execrows +UPDATE devices SET last_seen_at = clock_timestamp() +WHERE devices.id = $1 AND EXISTS ( + SELECT 1 FROM runtime_device_authority a + WHERE a.id = devices.id AND a.credential_hash = $2 +) +` + +type TouchAuthenticatedDeviceParams struct { + ID pgtype.UUID `json:"id"` + CredentialHash string `json:"credential_hash"` +} + +func (q *Queries) TouchAuthenticatedDevice(ctx context.Context, arg TouchAuthenticatedDeviceParams) (int64, error) { + result, err := q.db.Exec(ctx, touchAuthenticatedDevice, arg.ID, arg.CredentialHash) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go index 95ae078e4..135054c24 100644 --- a/services/agents-api/internal/engine/claude.go +++ b/services/agents-api/internal/engine/claude.go @@ -11,7 +11,7 @@ import ( func claudeProfile() Profile { return Profile{ - Placements: []string{"none", "openai_hosted"}, MCPBearer: true, + Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPBearer: true, ValidateConfiguration: validateClaudeConfiguration, ValidateTools: validateClaudeTools, ValidateFunctionResult: func(content []proto.FunctionResultContent) error { @@ -26,7 +26,7 @@ func claudeProfile() Profile { } func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error { - if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted") || hasDaemon || strings.TrimSpace(agent.Model) == "" { + if environment == nil || (environment.Type != "none" && environment.Type != "openai_hosted" && environment.Type != "self_hosted") || hasDaemon || strings.TrimSpace(agent.Model) == "" { return ErrInvalidInput } if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { @@ -39,7 +39,7 @@ func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, ha } func validateClaudeTools(environment *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if environment.Type == "openai_hosted" && len(mcp) != 0 { + if environment.Type != "none" && len(mcp) != 0 { return errors.New("The configured workspace profile does not support HTTP MCP tools.") } if err := validateClaudeMCP(mcp); err != nil { diff --git a/services/agents-api/internal/engine/codex.go b/services/agents-api/internal/engine/codex.go index bb79af7d5..14a75ab9a 100644 --- a/services/agents-api/internal/engine/codex.go +++ b/services/agents-api/internal/engine/codex.go @@ -12,7 +12,7 @@ func codexProfile() Profile { Placements: []string{"none", "self_hosted", "openai_hosted"}, WebSearchControl: true, TextVerbosity: true, MCPBearer: true, ValidateTools: func(environment *v1.Environment, hasDaemon bool, _ []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if len(mcp) != 0 && (environment == nil || (environment.Type != "none" && environment.Type != "self_hosted") || hasDaemon) { + if len(mcp) != 0 && (environment == nil || environment.Type != "none" || hasDaemon) { return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") } return nil diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go index e101a4df9..47574a16c 100644 --- a/services/agents-api/internal/engine/mcode.go +++ b/services/agents-api/internal/engine/mcode.go @@ -9,8 +9,8 @@ import ( ) func mcodeProfile() Profile { - return Profile{Placements: []string{"none", "openai_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { - if e == nil || (e.Type != "none" && e.Type != "openai_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.MultiAgent.Enabled || a.MultiAgent.MaxConcurrentSubagents != nil || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { + return Profile{Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { + if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.MultiAgent.Enabled || a.MultiAgent.MaxConcurrentSubagents != nil || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { return ErrInvalidInput } return nil diff --git a/services/agents-api/internal/execution/device_authority.go b/services/agents-api/internal/execution/device_authority.go new file mode 100644 index 000000000..8cd0ff502 --- /dev/null +++ b/services/agents-api/internal/execution/device_authority.go @@ -0,0 +1,33 @@ +package execution + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// authorizedRuntimePeer fences a connected socket against current credential +// authority. A stable device ID does not keep an old credential alive on rotation. +func authorizedRuntimePeer(ctx context.Context, s *store.Store, registry *gateway.Registry, id string) (*gateway.Session, error) { + peer, err := registry.LookupDevice(id) + if err != nil { + return nil, err + } + credential, found, err := s.GetDeviceCredential(ctx, id) + if err != nil { + return nil, err + } + if !found || !peer.AuthenticatedWith(credential.CredentialHash) { + peer.Close("Runtime authorization changed") + return nil, store.ErrNotFound + } + if peer.IsClosed() { + return nil, gateway.ErrSessionClosed + } + return peer, nil +} + +func (d *Dispatcher) authorizedPeer(ctx context.Context, id string) (*gateway.Session, error) { + return authorizedRuntimePeer(ctx, d.Store, d.Registry, id) +} diff --git a/services/agents-api/internal/execution/directory_preparation.go b/services/agents-api/internal/execution/directory_preparation.go index c82cee883..6d4aca014 100644 --- a/services/agents-api/internal/execution/directory_preparation.go +++ b/services/agents-api/internal/execution/directory_preparation.go @@ -26,11 +26,7 @@ func (d *Dispatcher) readPreparedDirectory(ctx context.Context, peer *gateway.Se func (d *Dispatcher) withPreparedWorkspace(owner context.Context, peer *gateway.Session, session store.Session, environment store.Environment, bound store.ExecutionDevice, consume func(context.Context, string) error) error { req := proto.PromptRequestPayload{AgentKind: session.Engine, AgentStateKey: "agents-api-" + session.ID, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} - release, err := d.configurePreparedEnvironment(owner, session, environment, bound, &req) - if release != nil { - defer release() - } - if err != nil { + if err := d.configurePreparedEnvironment(session, environment, bound, &req); err != nil { return ErrExecutionUnavailable } prepared, err := newPreparedStart(peer) diff --git a/services/agents-api/internal/execution/directory_preparation_test.go b/services/agents-api/internal/execution/directory_preparation_test.go index 5405d89a2..3ce3fd2aa 100644 --- a/services/agents-api/internal/execution/directory_preparation_test.go +++ b/services/agents-api/internal/execution/directory_preparation_test.go @@ -1,26 +1,19 @@ package execution import ( - "context" "errors" "testing" - "testing/synctest" - "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -func TestDirectoryPreparationBoundsConnectionResolution(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - d := &Dispatcher{EnvironmentConnection: func(ctx context.Context, _ store.Session, _ store.Environment) (EnvironmentConnection, error) { - <-ctx.Done() - return EnvironmentConnection{}, ctx.Err() - }} - started := time.Now() - result := d.readPreparedDirectory(context.Background(), nil, store.Session{}, store.Environment{Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, store.ExecutionDevice{}, proto.WorkspaceReadPayload{}) - if !errors.Is(result.err, ErrExecutionUnavailable) || time.Since(started) <= 0 || time.Since(started) > 45*time.Second { - t.Fatal("connection resolution did not have a bounded owner lifetime") - } - }) +func TestDirectoryPreparationRejectsForeignBindingBeforeTransport(t *testing.T) { + result := (&Dispatcher{}).readPreparedDirectory(t.Context(), nil, + store.Session{ID: "session", TenantID: "tenant"}, + store.Environment{ID: "environment", SessionID: "session", TenantID: "tenant", Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, + store.ExecutionDevice{EnvironmentID: "other"}, proto.WorkspaceReadPayload{}) + if !errors.Is(result.err, ErrExecutionUnavailable) { + t.Fatal("foreign binding reached transport", result.err) + } } diff --git a/services/agents-api/internal/execution/dispatcher.go b/services/agents-api/internal/execution/dispatcher.go index be445865d..8b13673c2 100644 --- a/services/agents-api/internal/execution/dispatcher.go +++ b/services/agents-api/internal/execution/dispatcher.go @@ -33,10 +33,7 @@ type Dispatcher struct { Store *store.Store Registry *gateway.Registry // Options resolves transient engine credentials; they are never stored here. - Options func(context.Context, store.Session) (map[string]any, error) - EnvironmentConnection func(context.Context, store.Session, store.Environment) (EnvironmentConnection, error) - // CloseEnvironmentConnections drains transport observations before releasing execution ownership. - CloseEnvironmentConnections func() + Options func(context.Context, store.Session) (map[string]any, error) // ManagedRuntimes is optional internal provisioning; it does not admit hosted API requests. ManagedRuntimes *RuntimeProviders } @@ -58,7 +55,7 @@ func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string if err != nil { return store.Turn{}, err } - peer, err := d.Registry.LookupDevice(bound.Device.ID) + peer, err := d.authorizedPeer(ctx, bound.Device.ID) if err != nil { return store.Turn{}, err } diff --git a/services/agents-api/internal/execution/engine_profile_test.go b/services/agents-api/internal/execution/engine_profile_test.go index a8900ae6c..7416907d3 100644 --- a/services/agents-api/internal/execution/engine_profile_test.go +++ b/services/agents-api/internal/execution/engine_profile_test.go @@ -12,14 +12,14 @@ import ( ) func TestAcceptedEnginePlacements(t *testing.T) { - for _, engine := range []string{"codex", "claude_sdk", "unregistered"} { + for _, engine := range []string{"codex", "claude_sdk", "mcode", "unregistered"} { for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { raw := json.RawMessage(`{"agent":{"model":"fixture"},"environment":{"type":"` + placement + `"`) if placement == "self_hosted" { - raw = append(raw, []byte(`,"workspace_directory":"/work"`)...) + raw = append(raw, []byte(`,"workspace_directory":"/workspace"`)...) } raw = append(raw, []byte(`}}`)...) - want := engine != "unregistered" && !(engine == "claude_sdk" && placement == "self_hosted") + want := engine != "unregistered" if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != want { t.Fatalf("%s/%s: %v", engine, placement, err) } diff --git a/services/agents-api/internal/execution/environment_admission.go b/services/agents-api/internal/execution/environment_admission.go index f2c564e4a..5196ba936 100644 --- a/services/agents-api/internal/execution/environment_admission.go +++ b/services/agents-api/internal/execution/environment_admission.go @@ -29,7 +29,7 @@ func (w *Worker) validateEnvironmentAdmission(engine string, configuration json. } switch snapshot.Environment.Type { case "self_hosted": - if w.dispatcher.EnvironmentConnection == nil { + if w.dispatcher.Registry == nil { return store.ErrInvalidInput } case "openai_hosted": diff --git a/services/agents-api/internal/execution/environment_directory.go b/services/agents-api/internal/execution/environment_directory.go index 31719ddf3..205dd4f65 100644 --- a/services/agents-api/internal/execution/environment_directory.go +++ b/services/agents-api/internal/execution/environment_directory.go @@ -85,7 +85,7 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe return } if reserved { - ready, err := w.bindSessionDevice(check, session, func(id string) bool { return w.directoryDeviceReady(id, session.Engine, placement, true) }) + ready, err := w.bindSessionDevice(check, session, func(id string) bool { return w.directoryDeviceReady(check, id, session.Engine, placement, true) }) if err != nil || !ready { return } @@ -93,10 +93,10 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe // Capture retains the public Turn after its native Run has been released. prepare := reserved || session.LastTurn != nil && session.LastTurn.ArtifactCaptureStarted bound, err := w.dispatcher.Store.GetSessionDevice(check, session.TenantID, session.ID) - if err != nil || !environmentDeviceMatches(session, environment, bound, placement) || !w.directoryDeviceReady(bound.ID, session.Engine, placement, prepare) { + if err != nil || !environmentDeviceMatches(session, environment, bound) || !w.directoryDeviceReady(check, bound.ID, session.Engine, placement, prepare) { return } - peer, err := w.dispatcher.Registry.LookupDevice(bound.ID) + peer, err := w.dispatcher.authorizedPeer(check, bound.ID) if err != nil { return } @@ -110,19 +110,16 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe return } -func (w *Worker) directoryDeviceReady(id, engine string, placement environmentPlacement, prepare bool) bool { +func (w *Worker) directoryDeviceReady(ctx context.Context, id, engine string, placement environmentPlacement, prepare bool) bool { if w.dispatcher.Registry == nil { return false } - peer, err := w.dispatcher.Registry.LookupDevice(id) + peer, err := w.dispatcher.authorizedPeer(ctx, id) if err != nil { return false } info, found, known := peer.AgentKindStatus(engine) - placementReady := info.Capabilities.RemoteEnvironment - if placement.Type == "openai_hosted" { - placementReady = info.Capabilities.LocalEnvironment - } + placementReady := info.Capabilities.LocalEnvironment return known && found && info.Available && placementReady && (!prepare || (info.Capabilities.Preparation && info.Capabilities.WorkspaceReadPreparation)) } diff --git a/services/agents-api/internal/execution/environment_file_write.go b/services/agents-api/internal/execution/environment_file_write.go index 806b04a50..7cdb1aee3 100644 --- a/services/agents-api/internal/execution/environment_file_write.go +++ b/services/agents-api/internal/execution/environment_file_write.go @@ -66,7 +66,7 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f return fileWriteResult{err: store.ErrNotFound} } placement, err := parseEnvironmentPlacement(environment.Configuration) - if err != nil || placement.Type != "openai_hosted" { + if err != nil || (placement.Type != "openai_hosted" && placement.Type != "self_hosted") { return unavailable } session, err := w.dispatcher.Store.GetSession(ctx, environment.TenantID, environment.SessionID) @@ -74,10 +74,10 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f return fileWriteResult{err: err} } bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) - if err != nil || !environmentDeviceMatches(session, environment, bound, placement) || w.dispatcher.Registry == nil { + if err != nil || !environmentDeviceMatches(session, environment, bound) || w.dispatcher.Registry == nil { return unavailable } - peer, err := w.dispatcher.Registry.LookupDevice(bound.ID) + peer, err := w.dispatcher.authorizedPeer(ctx, bound.ID) if err != nil { return unavailable } diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index fdc7f6525..bbd3f1121 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -2,9 +2,7 @@ package execution import ( "bytes" - "context" "encoding/json" - "errors" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -30,7 +28,7 @@ type environmentPlacement struct { // does not provision a Runtime, validate live authority, or admit hosted creation. func LocalWorkspaceConfiguration(configuration json.RawMessage) bool { placement, err := parseEnvironmentPlacement(configuration) - return err == nil && placement.Type == "openai_hosted" + return err == nil && (placement.Type == "openai_hosted" || placement.Type == "self_hosted") } func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacement, error) { @@ -40,7 +38,8 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem } switch placement.Type { case "self_hosted": - if placement.WorkspaceDirectory != "" && len(placement.CapabilityDirectories) == 0 { + if placement.WorkspaceDirectory == "/workspace" && len(placement.CapabilityDirectories) == 0 { + placement.NetworkAccess = "enabled" return placement, nil } case "openai_hosted": @@ -76,46 +75,29 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem return placement, store.ErrInvalidInput } -func environmentDeviceMatches(session store.Session, environment store.Environment, bound store.ExecutionDevice, placement environmentPlacement) bool { +func environmentDeviceMatches(session store.Session, environment store.Environment, bound store.ExecutionDevice) bool { if environment.SessionID != session.ID || environment.TenantID != session.TenantID { return false } - if placement.Type == "openai_hosted" { - return bound.EnvironmentID == environment.ID - } - return bound.EnvironmentID == "" + return bound.EnvironmentID == environment.ID } -func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session store.Session, environment store.Environment, bound store.ExecutionDevice, req *proto.PromptRequestPayload) (func(), error) { +func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environment store.Environment, bound store.ExecutionDevice, req *proto.PromptRequestPayload) error { placement, err := parseEnvironmentPlacement(environment.Configuration) - if err != nil || !environmentDeviceMatches(session, environment, bound, placement) { - return nil, store.ErrInvalidInput - } - if placement.Type == "openai_hosted" { - if placement.SystemPackages && !placement.ToolEnvironment { - return nil, store.ErrInvalidInput - } - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, Capabilities: len(placement.Plugins)+len(placement.CapabilityDirectories) > 0, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} - for _, metadata := range placement.Skills { - if store.ValidateInstalledSkillMetadata(metadata) != nil { - return nil, store.ErrInvalidInput - } - req.LocalEnvironment.Capabilities = true - } - req.LocalEnvironment.NetworkAccess = placement.NetworkAccess - req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) - return nil, nil + if err != nil || !environmentDeviceMatches(session, environment, bound) { + return store.ErrInvalidInput } - if d.EnvironmentConnection == nil { - return nil, errors.New("environment connection resolver is not configured") + if placement.SystemPackages && !placement.ToolEnvironment { + return store.ErrInvalidInput } - connection, err := d.EnvironmentConnection(ctx, session, environment) - if err != nil { - return connection.Release, err - } - if connection.URL == "" || connection.Token == "" || connection.Release == nil { - return connection.Release, errors.New("environment connection is incomplete") + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, Capabilities: len(placement.Plugins)+len(placement.CapabilityDirectories) > 0, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} + for _, metadata := range placement.Skills { + if store.ValidateInstalledSkillMetadata(metadata) != nil { + return store.ErrInvalidInput + } + req.LocalEnvironment.Capabilities = true } - req.RemoteEnvironment = &proto.RemoteEnvironment{ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, ConnectionURL: connection.URL, ConnectionToken: connection.Token} - return connection.Release, nil + req.LocalEnvironment.NetworkAccess = placement.NetworkAccess + req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) + return nil } diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index d0f0ab80d..922962670 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -2,7 +2,6 @@ package execution import ( "bytes" - "context" "encoding/json" "slices" "testing" @@ -16,7 +15,7 @@ func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"skills":[{"type":"skill_reference","skill_id":"skill-private","version":"1","name":"proof","description":"A proof."}]}`)} var request proto.PromptRequestPayload - _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request) + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request) if err != nil || request.LocalEnvironment == nil || !request.LocalEnvironment.Capabilities || len(request.LocalEnvironment.Skills) != 0 { t.Fatal("resolved Skill did not use the common installation descriptor", err) } @@ -28,7 +27,7 @@ func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { if !LocalWorkspaceConfiguration(environment.Configuration) { t.Fatal("admission demanded installed metadata before the creation transaction") } - if _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &proto.PromptRequestPayload{}); err == nil { + if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &proto.PromptRequestPayload{}); err == nil { t.Fatal("execution received an unresolved Skill selector") } } @@ -47,15 +46,12 @@ func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) } session := store.Session{ID: "session", TenantID: "tenant"} environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","network":{"access":"disabled"}}`)} - d := &Dispatcher{EnvironmentConnection: func(context.Context, store.Session, store.Environment) (EnvironmentConnection, error) { - t.Fatal("local placement resolved a remote transport") - return EnvironmentConnection{}, nil - }} + d := &Dispatcher{} for _, scope := range []string{"", "other", environment.ID} { var req proto.PromptRequestPayload - release, err := d.configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: scope}, &req) + err := d.configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: scope}, &req) if scope == environment.ID { - if err != nil || release != nil || req.LocalEnvironment == nil || req.LocalEnvironment.ID != environment.ID || req.RemoteEnvironment != nil || req.WorkDir != "" { + if err != nil || req.LocalEnvironment == nil || req.LocalEnvironment.ID != environment.ID || req.WorkDir != "" { t.Fatal("local identity was not preserved", err) } } else if err == nil || req.LocalEnvironment != nil { @@ -74,7 +70,7 @@ func TestNetworkPolicySurvivesPreparedBinding(t *testing.T) { t.Fatal(err) } var req proto.PromptRequestPayload - _, err = (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) + err = (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) if err != nil || req.LocalEnvironment == nil || req.LocalEnvironment.NetworkAccess != placement.NetworkAccess || !slices.Equal(req.LocalEnvironment.AllowedDomains, placement.AllowedDomains) { t.Fatal("prepared binding lost policy", req.LocalEnvironment, err) } @@ -86,7 +82,7 @@ func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"system":["jq"]}}`)} var req proto.PromptRequestPayload - _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment || !req.LocalEnvironment.SystemPackages { t.Fatal("system initialization requirement was lost", err) @@ -96,7 +92,7 @@ func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { t.Fatal("public admission requires a private execution receipt") } req = proto.PromptRequestPayload{} - if _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, + if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req); err == nil || req.LocalEnvironment != nil { t.Fatal("execution without the required initialization was admitted") } @@ -114,3 +110,22 @@ func TestLocalNetworkDefaultsAndSupportedPolicies(t *testing.T) { t.Fatal("disabled policy lost", got, err) } } + +func TestSelfHostedUsesSameLocalBinding(t *testing.T) { + session := store.Session{ID: "session", TenantID: "tenant"} + for _, workspace := range []string{"/workspace", "/other"} { + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + Configuration: []byte(`{"type":"self_hosted","workspace_directory":"` + workspace + `"}`)} + for _, binding := range []string{"", "foreign", environment.ID} { + var request proto.PromptRequestPayload + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: binding}, &request) + valid := binding == environment.ID && workspace == "/workspace" + if (err == nil) != valid { + t.Fatal(workspace, binding, err) + } + if valid && (request.LocalEnvironment == nil || request.LocalEnvironment.ID != environment.ID || request.LocalEnvironment.NetworkAccess != "enabled") { + t.Fatal("self-hosted placement did not retain common local contract", request.LocalEnvironment) + } + } + } +} diff --git a/services/agents-api/internal/execution/mcp_credentials_test.go b/services/agents-api/internal/execution/mcp_credentials_test.go index 1735442b3..1216278b1 100644 --- a/services/agents-api/internal/execution/mcp_credentials_test.go +++ b/services/agents-api/internal/execution/mcp_credentials_test.go @@ -34,7 +34,7 @@ func TestMCPFrozenCredentialAdmission(t *testing.T) { snapshot.Daemon = &DaemonConfig{WorkDir: "/tmp"} } if strings.HasPrefix(mode, "self-hosted") { - snapshot.Environment = &v1.Environment{Type: "self_hosted", WorkspaceDirectory: "/work"} + snapshot.Environment = &v1.Environment{Type: "self_hosted", WorkspaceDirectory: "/workspace"} } if mode == "changed selection" { binding.CredentialID = uuid.NewString() @@ -46,7 +46,7 @@ func TestMCPFrozenCredentialAdmission(t *testing.T) { rawTool, _ := json.Marshal(tool) snapshot.Agent.Tools = []json.RawMessage{rawTool} raw, _ := json.Marshal(snapshot) - valid := mode == "implicit" || mode == "explicit" || mode == "anonymous" || mode == "self-hosted anonymous" || mode == "self-hosted implicit" || mode == "self-hosted explicit" + valid := mode == "implicit" || mode == "explicit" || mode == "anonymous" for _, engine := range []string{"codex", "claude_sdk"} { supported := valid && (engine == "codex" || !strings.HasPrefix(mode, "self-hosted")) if err := (Policy{}).ValidateSessionConfiguration(engine, raw); (err == nil) != supported { diff --git a/services/agents-api/internal/execution/mcp_support.go b/services/agents-api/internal/execution/mcp_support.go index 97c92cc8c..764f23cec 100644 --- a/services/agents-api/internal/execution/mcp_support.go +++ b/services/agents-api/internal/execution/mcp_support.go @@ -26,7 +26,7 @@ func (p Policy) mcpExecutionCredentials(engine string, snapshot Snapshot, server fail := func(message string) (map[string]store.MCPCredentialBinding, error) { return nil, errors.New(message) } - if len(servers) > 0 && (!caps.MCPHTTPTools || snapshot.Environment == nil || (snapshot.Environment.Type != "none" && snapshot.Environment.Type != "self_hosted") || snapshot.Daemon != nil) { + if len(servers) > 0 && (!caps.MCPHTTPTools || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil) { return fail("device must support the service-side HTTP MCP profile") } selected, err := p.mcpCredentialBindings(engine, snapshot) @@ -38,17 +38,6 @@ func (p Policy) mcpExecutionCredentials(engine string, snapshot Snapshot, server return fail("device must advertise mcp_http_required") } } - if len(servers) > 0 && snapshot.Environment.Type == "self_hosted" { - if !caps.MCPHTTPRemoteEnvironment { - return fail("device must support service-side HTTP MCP with a remote environment") - } - if len(selected) > 0 && !caps.MCPHTTPRemoteBearerAuth { - return fail("device must advertise mcp_http_remote_bearer_auth") - } - if !caps.Preparation || !caps.RemoteEnvironment { - return fail("device must advertise preparation and remote_environment") - } - } if len(selected) > 0 && !caps.MCPHTTPBearerAuth { return fail("device must advertise mcp_http_bearer_auth") } diff --git a/services/agents-api/internal/execution/mcp_support_test.go b/services/agents-api/internal/execution/mcp_support_test.go index c1862f4aa..884cae103 100644 --- a/services/agents-api/internal/execution/mcp_support_test.go +++ b/services/agents-api/internal/execution/mcp_support_test.go @@ -22,7 +22,7 @@ func mcpSupportFixture(t *testing.T) (Snapshot, []proto.MCPHTTPServer, device.Ki t.Fatal(err) } caps := device.KindCapabilities{EnvironmentNone: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: true, - Preparation: true, RemoteEnvironment: true, MCPHTTPRemoteEnvironment: true, MCPHTTPRemoteBearerAuth: true} + Preparation: true} return snapshot, servers, caps } @@ -57,7 +57,7 @@ func TestMCPPublicBearerPolicyIsIndependentOfRuntimeCapabilities(t *testing.T) { func TestMCPExecutionChecksRequireVerifiedCapabilityCombinations(t *testing.T) { for _, placement := range []string{"none", "self_hosted"} { - for _, missing := range []string{"", "mcp", "bearer", "placement", "required", "preparation", "remote-mcp", "remote-bearer", "daemon", "environment"} { + for _, missing := range []string{"", "mcp", "bearer", "placement", "required", "preparation", "daemon", "environment"} { t.Run(placement+"/"+missing, func(t *testing.T) { snapshot, servers, caps := mcpSupportFixture(t) snapshot.Environment.Type = placement @@ -75,21 +75,17 @@ func TestMCPExecutionChecksRequireVerifiedCapabilityCombinations(t *testing.T) { case "bearer": caps.MCPHTTPBearerAuth = false case "placement": - caps.EnvironmentNone, caps.RemoteEnvironment = false, false + caps.EnvironmentNone = false case "required": caps.MCPHTTPRequired = false case "preparation": caps.Preparation = false - case "remote-mcp": - caps.MCPHTTPRemoteEnvironment = false - case "remote-bearer": - caps.MCPHTTPRemoteBearerAuth = false case "daemon": snapshot.Daemon = &DaemonConfig{WorkDir: "/work"} case "environment": snapshot.Environment = nil } - allowed := missing == "" || placement == "none" && (missing == "preparation" || missing == "remote-mcp" || missing == "remote-bearer") + allowed := placement == "none" && (missing == "" || missing == "preparation") selected, err := (Policy{}).mcpExecutionCredentials("codex", snapshot, servers, caps) if (err == nil) != allowed || allowed && len(selected) != 1 { t.Fatal("incorrect combined MCP capability decision", err) diff --git a/services/agents-api/internal/execution/mcp_test.go b/services/agents-api/internal/execution/mcp_test.go index 4b7ad6e97..bf3df9bf4 100644 --- a/services/agents-api/internal/execution/mcp_test.go +++ b/services/agents-api/internal/execution/mcp_test.go @@ -13,10 +13,10 @@ func TestMCPRequiresSupportedServicePlacement(t *testing.T) { }{ {"codex", `{"type":"none"}`, true}, {"claude_sdk", `{"type":"none"}`, true}, - {"claude_sdk", `{"type":"self_hosted","workspace_directory":"/work"}`, false}, + {"claude_sdk", `{"type":"self_hosted","workspace_directory":"/workspace"}`, false}, {"unavailable", `{"type":"none"}`, false}, {"codex", `null`, false}, - {"codex", `{"type":"self_hosted","workspace_directory":"/work"}`, true}, + {"codex", `{"type":"self_hosted","workspace_directory":"/workspace"}`, false}, } { raw, err := json.Marshal(map[string]any{"agent": map[string]any{"model": "model", "tools": []json.RawMessage{tool}}, "environment": json.RawMessage(profile.environment)}) if err != nil { diff --git a/services/agents-api/internal/execution/prepared_dispatch.go b/services/agents-api/internal/execution/prepared_dispatch.go index 50cd3bd04..107f5d524 100644 --- a/services/agents-api/internal/execution/prepared_dispatch.go +++ b/services/agents-api/internal/execution/prepared_dispatch.go @@ -9,11 +9,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -type EnvironmentConnection struct { - URL, Token string - Release func() -} - type EnvironmentRun struct { Reservation store.EnvironmentInputReservation Turn store.Turn @@ -44,7 +39,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionI if err != nil { return run, err } - peer, err := d.Registry.LookupDevice(bound.Device.ID) + peer, err := d.authorizedPeer(ctx, bound.Device.ID) if err != nil { return run, err } @@ -69,11 +64,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionI } messages = append(messages, text) } - release, err := d.configurePreparedEnvironment(owner, session, environment, bound.Device, &req) - if release != nil { - defer release() - } - if err != nil { + if err := d.configurePreparedEnvironment(session, environment, bound.Device, &req); err != nil { return run, err } prepared, err := newPreparedStart(peer) diff --git a/services/agents-api/internal/execution/runtime_connections.go b/services/agents-api/internal/execution/runtime_connections.go index d63f0106e..7808a9128 100644 --- a/services/agents-api/internal/execution/runtime_connections.go +++ b/services/agents-api/internal/execution/runtime_connections.go @@ -2,6 +2,7 @@ package execution import ( "context" + "errors" "github.com/google/uuid" @@ -29,31 +30,67 @@ func (r *runtimeLifecycle) observeConnection(ctx context.Context, owner store.Ru if bound.ID != owner.DeviceID || bound.EnvironmentID != owner.EnvironmentID { return store.ErrDeviceBindingConflict } - peer, err := r.registry.LookupDevice(owner.DeviceID) - connected := err == nil && !peer.IsClosed() - current := r.connections[owner.ID] - if connected { - // Initial connection is published only after bootstrap ownership is - // settled. Native execution readiness remains a separate preparation. - if !owner.CreateSettled || owner.State != "running" { - return nil - } - if current == nil || current.peer != peer { - generation := uuid.NewString() - if err := r.store.ReplaceEnvironmentConnection(ctx, owner.TenantID, owner.EnvironmentID, generation); err != nil { - return err - } - current = &runtimeConnection{peer: peer, generation: generation} - r.connections[owner.ID] = current + if !owner.CreateSettled || owner.State != "running" { + return nil + } + peer, err := authorizedRuntimePeer(ctx, r.store, r.registry, owner.DeviceID) + connected := err == nil + if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, gateway.ErrSessionClosed) && !errors.Is(err, gateway.ErrDeviceNotRegistered) { + return err + } + return observeRuntimeConnection(ctx, r.store, r.connections, owner.TenantID, owner.EnvironmentID, peer, connected) +} + +// Each Environment has one observer: the hosted lifecycle or the Worker loop for +// enrolled user compute. Both publish the same durable generation/revision rules. +func observeRuntimeConnection(ctx context.Context, s *store.Store, connections map[string]*runtimeConnection, tenant, environment string, peer *gateway.Session, connected bool) error { + current := connections[environment] + if connected && (current == nil || current.peer != peer) { + generation := uuid.NewString() + if err := s.ReplaceEnvironmentConnection(ctx, tenant, environment, generation); err != nil { + return err } + current = &runtimeConnection{peer: peer, generation: generation} + connections[environment] = current } if current == nil || current.connected == connected { return nil } current.revision++ - if err := r.store.ObserveEnvironmentConnection(ctx, owner.TenantID, owner.EnvironmentID, current.generation, current.revision, connected); err != nil { + if err := s.ObserveEnvironmentConnection(ctx, tenant, environment, current.generation, current.revision, connected); err != nil { return err } current.connected = connected return nil } + +func (w *Worker) observeEnrolledRuntimes(ctx context.Context) error { + bindings, err := w.dispatcher.Store.ListEnrolledRuntimeBindings(ctx) + if err != nil { + return err + } + live := make(map[string]bool, len(bindings)) + for _, bound := range bindings { + live[bound.EnvironmentID] = true + peer, err := w.dispatcher.authorizedPeer(ctx, bound.DeviceID) + connected := err == nil + if err != nil && !errors.Is(err, store.ErrNotFound) && !errors.Is(err, gateway.ErrSessionClosed) && !errors.Is(err, gateway.ErrDeviceNotRegistered) { + return err + } + if err := observeRuntimeConnection(ctx, w.dispatcher.Store, w.enrolledConnections, bound.TenantID, bound.EnvironmentID, peer, connected); err != nil { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrInvalidInput) { + continue + } + return err + } + } + for id, current := range w.enrolledConnections { + if !live[id] { + if current.peer != nil { + current.peer.Close("Environment is no longer available") + } + delete(w.enrolledConnections, id) + } + } + return nil +} diff --git a/services/agents-api/internal/execution/runtime_connections_test.go b/services/agents-api/internal/execution/runtime_connections_test.go new file mode 100644 index 000000000..492447b55 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_connections_test.go @@ -0,0 +1,26 @@ +package execution + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestRuntimeCleanupDropsEnvironmentConnectionAndOnlyOwnedInitialization(t *testing.T) { + first := store.RuntimeAllocation{ID: "allocation-one", EnvironmentID: "environment-one"} + second := store.RuntimeAllocation{ID: "allocation-two", EnvironmentID: "environment-two"} + retained := &runtimeConnection{} + initializing := &runtimeInitialization{owner: second} + r := &runtimeLifecycle{ + connections: map[string]*runtimeConnection{first.EnvironmentID: {}, second.EnvironmentID: retained}, + initializing: initializing, + } + r.clearRuntimeState(first) + if len(r.connections) != 1 || r.connections[second.EnvironmentID] != retained || r.initializing != initializing { + t.Fatal("cleanup retained the retired connection or discarded another Runtime's state") + } + r.clearRuntimeState(second) + if len(r.connections) != 0 || r.initializing != nil { + t.Fatal("cleanup retained owned connection or initialization") + } +} diff --git a/services/agents-api/internal/execution/runtime_initialization_real_test.go b/services/agents-api/internal/execution/runtime_initialization_real_test.go deleted file mode 100644 index a067cbe91..000000000 --- a/services/agents-api/internal/execution/runtime_initialization_real_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package execution - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "os" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type installerObservation struct { - sandbox.Provider - t *testing.T -} - -func (p installerObservation) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - result, err := p.Provider.RunCommand(ctx, r, c) - if err != nil || result.ExitCode != 0 { - p.t.Logf("trusted fixture installer exit=%d stdout=%q stderr=%q error=%v", result.ExitCode, result.Stdout, result.Stderr, err) - } - return result, err -} - -func TestRealE2BInitialFileInstaller(t *testing.T) { - keyFile, template := os.Getenv("PARSAR_E2B_TEST_KEY_FILE"), os.Getenv("PARSAR_E2B_TEST_TEMPLATE") - if keyFile == "" || template == "" { - t.Skip("actual E2B account and qualified Runtime template required") - } - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal("private E2B key unavailable") - } - provider, err := e2b.New(e2b.Config{InstallationID: uuid.NewString(), APIKey: strings.TrimSpace(string(key)), Template: template, LeaseSeconds: 7200}) - if err != nil { - t.Fatal(err) - } - p := installerObservation{Provider: provider, t: t} - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) - defer cancel() - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://example.com/api/v1", Credential: uuid.NewString(), NetworkAccess: "enabled"} - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - if err := p.Kill(ctx, b.Reference); err != nil { - t.Error(err) - } - }) - if _, err := p.Create(ctx, b); err != nil { - t.Fatal(err) - } - for _, body := range [][]byte{{}, []byte("binary\x00\xff\n"), bytes.Repeat([]byte{0xA5}, 50<<20)} { - file := store.InitialFileMetadata{Path: "/workspace/nested/input.bin"} - size := int64(len(body)) - file.SizeBytes = &size - operation, stop := context.WithTimeout(ctx, 2*time.Minute) - err := installInitialFile(operation, p, b.Reference, file, body) - stop() - if err != nil { - t.Fatal("actual shared installer", err) - } - result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sha256sum", "/environment/workspace/nested/input.bin"}}) - digest := sha256.Sum256(body) - if err != nil || result.ExitCode != 0 || !strings.HasPrefix(result.Stdout, hex.EncodeToString(digest[:])) { - t.Fatal("installed bytes differ", err) - } - } - result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", `from pathlib import Path -outside = Path('/tmp/initial-file-outside') -outside.mkdir() -(outside / 'data').write_text('preserved') -Path('/environment/workspace/escape-parent').symlink_to(outside, target_is_directory=True) -Path('/environment/workspace/escape-file').symlink_to(outside / 'data') -`}}) - if err != nil || result.ExitCode != 0 { - t.Fatal("symlink fixture", err) - } - for _, destination := range []string{"/workspace/escape-parent/data", "/workspace/escape-file"} { - size := int64(7) - if err := installInitialFile(ctx, p, b.Reference, store.InitialFileMetadata{Path: destination, SizeBytes: &size}, []byte("changed")); err == nil { - t.Fatal("initialization followed symlink", destination) - } - } - result, err = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/tmp/initial-file-outside/data"}}) - if err != nil || result.ExitCode != 0 || result.Stdout != "preserved" { - t.Fatal("initialization changed bytes outside the workspace", err) - } - -} diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go index 0de84ff7f..fb306b9f3 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -212,10 +212,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAlloc if err != nil { return err } - delete(r.connections, owner.ID) - if r.initializing != nil && r.initializing.owner.ID == owner.ID { - r.initializing = nil - } + r.clearRuntimeState(owner) } else if err := r.observeConnection(ctx, owner); err != nil { return err } @@ -284,6 +281,14 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAlloc return err } +// Allocation identity owns initialization; Environment identity owns connectivity. +func (r *runtimeLifecycle) clearRuntimeState(owner store.RuntimeAllocation) { + delete(r.connections, owner.EnvironmentID) + if r.initializing != nil && r.initializing.owner.ID == owner.ID { + r.initializing = nil + } +} + func runtimeReference(owner store.RuntimeAllocation) sandbox.Reference { return sandbox.Reference{TenantID: owner.TenantID, EnvironmentID: owner.EnvironmentID, AllocationID: owner.ID} } diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 946cb9ec0..02a20cc43 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -3,7 +3,6 @@ package execution import ( "encoding/json" "errors" - "path" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" @@ -26,7 +25,7 @@ func (p Policy) ValidateSessionConfiguration(engine string, configuration json.R return err } if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" { - if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !path.IsAbs(snapshot.Environment.WorkspaceDirectory) || strings.ContainsAny(snapshot.Environment.WorkspaceDirectory, "\x00\r\n\\") || len(snapshot.Environment.CapabilityDirectories) != 0 { + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || snapshot.Environment.WorkspaceDirectory != "/workspace" || len(snapshot.Environment.CapabilityDirectories) != 0 { return store.ErrInvalidInput } } @@ -105,10 +104,7 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if _, err := p.mcpExecutionCredentials(engine, snapshot, mcp, caps); err != nil { return device.KindCapabilities{}, err } - if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" && (!caps.Preparation || !caps.RemoteEnvironment) { - return fail("device must advertise preparation and remote_environment") - } - if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { + if snapshot.Environment != nil && (snapshot.Environment.Type == "openai_hosted" || snapshot.Environment.Type == "self_hosted") { if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { return fail("device must advertise local preparation, workspace reads and output export") } diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index 42586ebd2..98054f7f2 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -13,14 +13,15 @@ import ( // Worker owns queued work; the database lease excludes a second execution service. type Worker struct { - dispatcher *Dispatcher - admission *store.Store - lease *store.ExecutionLease - directoryReads chan directoryReadRequest - fileWrites chan fileWriteRequest - stopped chan struct{} - stopOnce sync.Once - runtimes *runtimeLifecycle + dispatcher *Dispatcher + admission *store.Store + lease *store.ExecutionLease + directoryReads chan directoryReadRequest + fileWrites chan fileWriteRequest + stopped chan struct{} + stopOnce sync.Once + runtimes *runtimeLifecycle + enrolledConnections map[string]*runtimeConnection } func StartWorker(ctx context.Context, dispatcher *Dispatcher) (*Worker, error) { @@ -30,7 +31,7 @@ func StartWorker(ctx context.Context, dispatcher *Dispatcher) (*Worker, error) { } owned := *dispatcher owned.Store = lease.Store() - worker := &Worker{dispatcher: &owned, admission: dispatcher.Store, lease: lease, directoryReads: make(chan directoryReadRequest), fileWrites: make(chan fileWriteRequest), stopped: make(chan struct{})} + worker := &Worker{dispatcher: &owned, admission: dispatcher.Store, lease: lease, directoryReads: make(chan directoryReadRequest), fileWrites: make(chan fileWriteRequest), stopped: make(chan struct{}), enrolledConnections: make(map[string]*runtimeConnection)} worker.runtimes, err = newRuntimeLifecycle(owned.Store, owned.Registry, owned.ManagedRuntimes) if err != nil { _ = lease.Close(context.Background()) @@ -105,9 +106,6 @@ func (w *Worker) Run(ctx context.Context) error { w.runtimes.gate <- struct{}{} <-w.runtimes.gate } - if w.dispatcher.CloseEnvironmentConnections != nil { - w.dispatcher.CloseEnvironmentConnections() - } closeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) defer stop() _ = w.lease.Close(closeCtx) @@ -202,6 +200,9 @@ func (w *Worker) Run(ctx context.Context) error { if _, err := w.dispatcher.Store.ExpireEnvironmentInputs(ctx); err != nil { return err } + if err := w.observeEnrolledRuntimes(ctx); err != nil { + return err + } if len(active) == 4 { continue } diff --git a/services/agents-api/internal/execution/worker_device.go b/services/agents-api/internal/execution/worker_device.go index c4cb1ce9d..c71627880 100644 --- a/services/agents-api/internal/execution/worker_device.go +++ b/services/agents-api/internal/execution/worker_device.go @@ -32,10 +32,7 @@ func (w *Worker) bindDevice(ctx context.Context, tenantID, sessionID string) (bo if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { return false, err } - if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" && w.dispatcher.EnvironmentConnection == nil { - return false, nil - } - return w.bindSessionDevice(ctx, session, func(id string) bool { return w.ready(id, session.Engine, snapshot) }) + return w.bindSessionDevice(ctx, session, func(id string) bool { return w.ready(ctx, id, session.Engine, snapshot) }) } func (w *Worker) bindSessionDevice(ctx context.Context, session store.Session, ready func(string) bool) (bool, error) { @@ -43,20 +40,19 @@ func (w *Worker) bindSessionDevice(ctx context.Context, session store.Session, r if json.Unmarshal(session.Configuration, &snapshot) != nil { return false, store.ErrInvalidInput } - if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { + if snapshot.Environment != nil && (snapshot.Environment.Type == "openai_hosted" || snapshot.Environment.Type == "self_hosted") { environment, err := w.dispatcher.Store.GetSessionEnvironment(ctx, session.TenantID, session.ID) if err != nil { return false, err } - placement, err := parseEnvironmentPlacement(environment.Configuration) - if err != nil { + if _, err := parseEnvironmentPlacement(environment.Configuration); err != nil { return false, nil } bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) if errors.Is(err, store.ErrNotFound) { return false, nil } - return err == nil && environmentDeviceMatches(session, environment, bound, placement) && ready(bound.ID), err + return err == nil && environmentDeviceMatches(session, environment, bound) && ready(bound.ID), err } bound, err := w.dispatcher.Store.GetSessionDevice(ctx, session.TenantID, session.ID) if err == nil { @@ -79,8 +75,8 @@ func (w *Worker) bindSessionDevice(ctx context.Context, session store.Session, r return false, nil } -func (w *Worker) ready(deviceID, engine string, snapshot Snapshot) bool { - peer, err := w.dispatcher.Registry.LookupDevice(deviceID) +func (w *Worker) ready(ctx context.Context, deviceID, engine string, snapshot Snapshot) bool { + peer, err := w.dispatcher.authorizedPeer(ctx, deviceID) if err != nil { return false } diff --git a/services/agents-api/internal/executor/codex/config.go b/services/agents-api/internal/executor/codex/config.go deleted file mode 100644 index 12473f607..000000000 --- a/services/agents-api/internal/executor/codex/config.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package codex implements the native executor registry, separate from the public Agents API. -package codex - -import ( - "context" - "errors" - "net" - "net/url" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type EnvironmentStore interface { - GetEnvironment(context.Context, string, string) (store.Environment, error) - AuthenticateEnvironmentExecutor(context.Context, string, string) (string, error) -} - -// ScopedKey binds a purpose-specific native transport credential to one Environment. -type ScopedKey struct { - TokenSHA256 string `json:"token_sha256"` - TenantID string `json:"tenant_id"` - EnvironmentID string `json:"environment_id"` -} - -type Config struct { - Store EnvironmentStore - CheckOwnership func(context.Context) error - // Callbacks must honor their context and use the current execution writer. - // They run outside the registry mutex and must not call Registry.Close. - ReplaceConnection func(context.Context, string, string, string) error - ObserveConnection func(context.Context, string, string, string, int64, bool) error - PublicURL string -} - -func validateConfig(c Config) (string, error) { - if c.Store == nil || c.CheckOwnership == nil || c.ReplaceConnection == nil || c.ObserveConnection == nil { - return "", errors.New("executor registry requires Store, execution ownership and connection lifecycle callbacks") - } - u, err := url.Parse(c.PublicURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { - return "", errors.New("executor URL must be an absolute origin without credentials") - } - switch u.Scheme { - case "https": - u.Scheme = "wss" - case "http": - ip := net.ParseIP(u.Hostname()) - if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { - return "", errors.New("executor HTTP is allowed only on loopback") - } - u.Scheme = "ws" - default: - return "", errors.New("executor URL must use HTTPS or loopback HTTP") - } - u.Path, u.RawPath = "", "" - return strings.TrimRight(u.String(), "/"), nil -} diff --git a/services/agents-api/internal/executor/codex/credentials.go b/services/agents-api/internal/executor/codex/credentials.go deleted file mode 100644 index 7895bc0a7..000000000 --- a/services/agents-api/internal/executor/codex/credentials.go +++ /dev/null @@ -1,84 +0,0 @@ -package codex - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func (r *Registry) executorCredential(w http.ResponseWriter, req *http.Request, environment string) (ScopedKey, bool) { - // Registration maps and durable ownership must use the same exact identity. - id, err := uuid.Parse(environment) - if err != nil || id.String() != environment { - writeError(w, http.StatusUnauthorized) - return ScopedKey{}, false - } - hash, ok := credentialHash(req) - if !ok { - writeError(w, http.StatusUnauthorized) - return ScopedKey{}, false - } - r.mu.Lock() - _, wrongPurpose := r.harnessKeys[hash] - r.mu.Unlock() - if wrongPurpose { - writeError(w, http.StatusUnauthorized) - return ScopedKey{}, false - } - ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) - defer cancel() - digest := hex.EncodeToString(hash[:]) - tenant, err := r.source.AuthenticateEnvironmentExecutor(ctx, environment, digest) - if !writeCredentialError(w, err) { - return ScopedKey{}, false - } - return ScopedKey{TenantID: tenant, EnvironmentID: environment, TokenSHA256: digest}, true -} - -func credentialHash(req *http.Request) ([32]byte, bool) { - parts := strings.Fields(req.Header.Get("Authorization")) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return [32]byte{}, false - } - return sha256.Sum256([]byte(parts[1])), true -} - -func (r *Registry) currentExecutor(ctx context.Context, key ScopedKey) error { - tenant, err := r.source.AuthenticateEnvironmentExecutor(ctx, key.EnvironmentID, key.TokenSHA256) - if err == nil && tenant != key.TenantID { - return store.ErrNotFound - } - return err -} - -func (r *Registry) checkCurrentExecutor(w http.ResponseWriter, req *http.Request, key ScopedKey) bool { - ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) - defer cancel() - return writeCredentialError(w, r.currentExecutor(ctx, key)) -} - -func (r *Registry) executorAuthorized(ctx context.Context, key ScopedKey) error { - if err := r.authorized(ctx, key); err != nil { - return err - } - return r.currentExecutor(ctx, key) -} - -func writeCredentialError(w http.ResponseWriter, err error) bool { - if err == nil { - return true - } - status := http.StatusServiceUnavailable - if errors.Is(err, store.ErrNotFound) { - status = http.StatusUnauthorized - } - writeError(w, status) - return false -} diff --git a/services/agents-api/internal/executor/codex/credentials_test.go b/services/agents-api/internal/executor/codex/credentials_test.go deleted file mode 100644 index 7365a4a2b..000000000 --- a/services/agents-api/internal/executor/codex/credentials_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package codex - -import ( - "context" - "sync" - "testing" -) - -type delayedCredential struct { - EnvironmentStore - hash string - once sync.Once - observed chan struct{} - release chan struct{} -} - -func (s *delayedCredential) AuthenticateEnvironmentExecutor(ctx context.Context, environment, hash string) (string, error) { - tenant, err := s.EnvironmentStore.AuthenticateEnvironmentExecutor(ctx, environment, hash) - if hash == s.hash { - s.once.Do(func() { - close(s.observed) - select { - case <-s.release: - case <-ctx.Done(): - } - }) - } - return tenant, err -} - -func TestRotatedCredentialFencesDelayedRegistration(t *testing.T) { - f := newFixture(t) - key := f.keys[0] - source := &delayedCredential{EnvironmentStore: f.source, hash: key.TokenSHA256, observed: make(chan struct{}), release: make(chan struct{})} - f.registry.source = source - done := make(chan struct{}) - go func() { - defer close(done) - f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 401) - }() - <-source.observed - next := "new-executor-credential" - f.source.mu.Lock() - f.source.keys[key.EnvironmentID] = digest(next) - f.source.mu.Unlock() - reg := f.register(t, key.EnvironmentID, next, nativeRequest(), 200) - socket := dial(t, reg.URL) - defer socket.Close() - close(source.release) - <-done - awaitPresence(t, f, key.EnvironmentID, true) - f.registry.mu.Lock() - current := f.registry.registrations[key.EnvironmentID].id - f.registry.mu.Unlock() - if current != reg.ExecutorRegistrationID { - t.Fatal("delayed revoked request replaced the current registration") - } - nativePOST(t, f, key.EnvironmentID, "validate", f.tokens[0], ValidationRequest{}, 401, nil) -} diff --git a/services/agents-api/internal/executor/codex/harness.go b/services/agents-api/internal/executor/codex/harness.go deleted file mode 100644 index 066726ae5..000000000 --- a/services/agents-api/internal/executor/codex/harness.go +++ /dev/null @@ -1,185 +0,0 @@ -package codex - -import ( - "crypto/sha256" - "crypto/subtle" - "net/http" - "time" -) - -const maxHarnessGrants = 32 - -type harnessGrant struct { - credential *harnessCredential - publicKey PublicKey - authorization [32]byte - expires time.Time - executor *connection - harness *connection - validated bool -} - -// @Summary Authorize an execution-owned harness key without disrupting an existing connection -// @Tags Native executor registry -// @Accept json -// @Produce json -// @Param environment path string true "Environment ID" -// @Param request body ConnectRequest true "Native harness public key" -// @Success 200 {object} ConnectResponse -// @Failure 400,401,404,429,503 {object} RegistryError -// @Router /cloud/environment/{environment}/connect [post] -func (r *Registry) connect(w http.ResponseWriter, req *http.Request) { - environment := req.PathValue("environment") - credential, ok := r.harnessCredential(req, environment) - if !ok { - writeError(w, http.StatusUnauthorized) - return - } - if !r.check(w, req, credential.key) { - return - } - var body ConnectRequest - if !decodeRequest(w, req, &body) { - return - } - if !body.HarnessPublicKey.valid() { - writeError(w, http.StatusBadRequest) - return - } - ticket, err := capability() - if err != nil { - writeError(w, http.StatusServiceUnavailable) - return - } - authorization, err := capability() - if err != nil { - writeError(w, http.StatusServiceUnavailable) - return - } - r.mu.Lock() - if !r.harnessCredentialActiveLocked(credential) { - r.mu.Unlock() - writeError(w, http.StatusUnauthorized) - return - } - reg := r.registrations[environment] - if r.closed || reg == nil || reg.socket == nil { - r.mu.Unlock() - writeError(w, http.StatusServiceUnavailable) - return - } - now := time.Now() - for hash, grant := range reg.grants { - if grant.executor != reg.socket || (grant.harness == nil && !now.Before(grant.expires)) { - delete(reg.grants, hash) - } - } - if len(reg.grants) >= maxHarnessGrants { - r.mu.Unlock() - writeError(w, http.StatusTooManyRequests) - return - } - reg.grants[sha256.Sum256([]byte(ticket))] = &harnessGrant{credential: credential, publicKey: body.HarnessPublicKey, authorization: sha256.Sum256([]byte(authorization)), expires: now.Add(ticketLifetime), executor: reg.socket} - response := ConnectResponse{ - RegistrationResponse: RegistrationResponse{EnvironmentID: environment, ExecutorRegistrationID: reg.id, SecurityProfile: securityProfile, URL: r.publicWS + "/cloud/environment/" + environment + "/harness/" + reg.id + "?ticket=" + ticket}, - ExecutorPublicKey: reg.publicKey, HarnessKeyAuthorization: authorization, - } - r.mu.Unlock() - writeJSON(w, http.StatusOK, response) -} - -// @Summary Validate a connected harness key proved by the native Noise handshake -// @Tags Native executor registry -// @Accept json -// @Produce json -// @Param environment path string true "Environment ID" -// @Param request body ValidationRequest true "Native harness key authorization" -// @Success 200 {object} ValidationResponse -// @Failure 400,401,404,503 {object} RegistryError -// @Router /cloud/environment/{environment}/validate [post] -func (r *Registry) validate(w http.ResponseWriter, req *http.Request) { - environment := req.PathValue("environment") - key, ok := r.executorCredential(w, req, environment) - if !ok { - return - } - if !r.check(w, req, key) { - return - } - var body ValidationRequest - if !decodeRequest(w, req, &body) { - return - } - if !body.HarnessPublicKey.valid() { - writeError(w, http.StatusBadRequest) - return - } - authorization := sha256.Sum256([]byte(body.HarnessKeyAuthorization)) - valid := false - r.mu.Lock() - reg := r.registrations[environment] - if !r.closed && reg != nil && reg.id == body.ExecutorRegistrationID && reg.key.TokenSHA256 == key.TokenSHA256 && reg.socket != nil { - for _, grant := range reg.grants { - if r.harnessCredentialActiveLocked(grant.credential) && grant.executor == reg.socket && grant.harness != nil && grant.harness == reg.socket.peer && !grant.validated && time.Now().Before(grant.expires) && grant.publicKey == body.HarnessPublicKey && subtle.ConstantTimeCompare(authorization[:], grant.authorization[:]) == 1 { - grant.validated = true - valid = true - break - } - } - } - r.mu.Unlock() - writeJSON(w, http.StatusOK, ValidationResponse{Valid: valid}) -} - -// @Summary Attach one harness using a short-lived connection capability -// @Tags Native executor registry -// @Param environment path string true "Environment ID" -// @Param registration path string true "Registration ID" -// @Param ticket query string true "Private connection capability" -// @Success 101 {string} string "WebSocket upgrade" -// @Failure 401,404,409,503 {object} RegistryError -// @Router /cloud/environment/{environment}/harness/{registration} [get] -func (r *Registry) connectHarness(w http.ResponseWriter, req *http.Request) { - environment, id := req.PathValue("environment"), req.PathValue("registration") - ticket := sha256.Sum256([]byte(req.URL.Query().Get("ticket"))) - r.mu.Lock() - reg := r.registrations[environment] - var grant *harnessGrant - if reg != nil { - grant = reg.grants[ticket] - } - valid := r.harnessAttachable(reg, grant, id) - r.mu.Unlock() - if !valid { - writeError(w, http.StatusUnauthorized) - return - } - if !r.check(w, req, grant.credential.key) || !r.checkCurrentExecutor(w, req, reg.key) { - return - } - r.mu.Lock() - if r.registrations[environment] != reg || !r.harnessAttachable(reg, grant, id) { - r.mu.Unlock() - writeError(w, http.StatusUnauthorized) - return - } - if reg.socket.peer != nil { - r.mu.Unlock() - writeError(w, http.StatusConflict) - return - } - socket, err := nativeUpgrader.Upgrade(w, req, nil) - if err != nil { - r.mu.Unlock() - return - } - c := &connection{socket: socket, peer: reg.socket} - reg.socket.peer = c - grant.harness = c - r.mu.Unlock() - r.serveConnection(environment, reg, c) -} - -func (r *Registry) harnessAttachable(reg *registration, grant *harnessGrant, id string) bool { - return !r.closed && reg != nil && reg.id == id && grant != nil && r.harnessCredentialActiveLocked(grant.credential) && grant.executor == reg.socket && grant.harness == nil && time.Now().Before(grant.expires) -} diff --git a/services/agents-api/internal/executor/codex/harness_credentials.go b/services/agents-api/internal/executor/codex/harness_credentials.go deleted file mode 100644 index bc5a2df91..000000000 --- a/services/agents-api/internal/executor/codex/harness_credentials.go +++ /dev/null @@ -1,104 +0,0 @@ -package codex - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -const maxHarnessCredentials = 32 - -var ErrHarnessCapacity = errors.New("native harness credential capacity reached") - -type harnessCredential struct { - key ScopedKey - hash [32]byte - owner context.Context - stop func() bool -} - -// IssueHarnessCredential authorizes one execution owner, spanning preparation and its Run. -// The caller must release ownership after execution; cancellation also revokes it. -// Only the returned bearer is secret. The registry retains its digest, never the bearer. -func (r *Registry) IssueHarnessCredential(owner context.Context, tenant, environment string) (string, func(), error) { - tenantID, tenantErr := uuid.Parse(tenant) - environmentID, environmentErr := uuid.Parse(environment) - if tenantErr != nil || environmentErr != nil || tenantID == uuid.Nil || environmentID == uuid.Nil { - return "", nil, store.ErrInvalidInput - } - key := ScopedKey{TenantID: tenantID.String(), EnvironmentID: environmentID.String()} - if err := r.authorized(owner, key); err != nil { - return "", nil, err - } - token, err := capability() - if err != nil { - return "", nil, err - } - hash := sha256.Sum256([]byte(token)) - key.TokenSHA256 = hex.EncodeToString(hash[:]) - credential := &harnessCredential{key: key, hash: hash, owner: owner} - release := func() { r.releaseHarnessCredential(credential) } - r.mu.Lock() - defer r.mu.Unlock() - if err := owner.Err(); err != nil { - return "", nil, err - } - if r.closed { - return "", nil, errors.New("native executor registry is closed") - } - if len(r.harnessKeys) >= maxHarnessCredentials { - return "", nil, ErrHarnessCapacity - } - if r.harnessKeys[hash] != nil { - return "", nil, errors.New("native harness credential collision") - } - r.harnessKeys[hash] = credential - credential.stop = context.AfterFunc(owner, release) - return token, release, nil -} - -func (r *Registry) harnessCredential(req *http.Request, environment string) (*harnessCredential, bool) { - hash, ok := credentialHash(req) - if !ok { - return nil, false - } - r.mu.Lock() - defer r.mu.Unlock() - credential := r.harnessKeys[hash] - return credential, r.harnessCredentialActiveLocked(credential) && credential.key.EnvironmentID == environment -} - -func (r *Registry) harnessCredentialActiveLocked(credential *harnessCredential) bool { - return !r.closed && credential != nil && credential.owner.Err() == nil && r.harnessKeys[credential.hash] == credential -} - -func (r *Registry) releaseHarnessCredential(credential *harnessCredential) { - r.mu.Lock() - if r.harnessKeys[credential.hash] != credential { - r.mu.Unlock() - return - } - delete(r.harnessKeys, credential.hash) - credential.stop() - reg := r.registrations[credential.key.EnvironmentID] - if reg == nil { - r.mu.Unlock() - return - } - var observation *connectionObservation - for ticket, grant := range reg.grants { - if grant.credential == credential { - delete(reg.grants, ticket) - if grant.harness != nil { - observation = r.closeConnectionLocked(reg, grant.harness) - } - } - } - r.mu.Unlock() - _ = r.deliverObservation(observation) -} diff --git a/services/agents-api/internal/executor/codex/harness_credentials_test.go b/services/agents-api/internal/executor/codex/harness_credentials_test.go deleted file mode 100644 index 2cf487d96..000000000 --- a/services/agents-api/internal/executor/codex/harness_credentials_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package codex - -import ( - "context" - "errors" - "sync/atomic" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func issueHarness(t *testing.T, f fixture, owner context.Context) (string, func()) { - t.Helper() - key := f.keys[0] - token, release, err := f.registry.IssueHarnessCredential(owner, key.TenantID, key.EnvironmentID) - if err != nil { - t.Fatal(err) - } - t.Cleanup(release) - return token, release -} - -func TestHarnessCredentialCapacityAndOwnership(t *testing.T) { - f := newFixture(t) - k := f.keys[0] - for _, ids := range [][2]string{{"invalid", k.EnvironmentID}, {k.TenantID, "invalid"}, {uuid.Nil.String(), k.EnvironmentID}} { - if _, _, err := f.registry.IssueHarnessCredential(t.Context(), ids[0], ids[1]); !errors.Is(err, store.ErrInvalidInput) { - t.Fatal("invalid ownership accepted", err) - } - } - for _, ids := range [][2]string{{f.keys[1].TenantID, k.EnvironmentID}, {k.TenantID, uuid.NewString()}} { - if _, _, err := f.registry.IssueHarnessCredential(t.Context(), ids[0], ids[1]); !errors.Is(err, store.ErrNotFound) { - t.Fatal("foreign or absent Environment accepted", err) - } - } - owner, cancel := context.WithCancel(t.Context()) - cancel() - if _, _, err := f.registry.IssueHarnessCredential(owner, k.TenantID, k.EnvironmentID); !errors.Is(err, context.Canceled) { - t.Fatal("cancelled owner accepted", err) - } - var firstToken string - var firstRelease func() - for i := range maxHarnessCredentials { - token, release := issueHarness(t, f, t.Context()) - if i == 0 { - firstToken, firstRelease = token, release - } - } - if _, _, err := f.registry.IssueHarnessCredential(t.Context(), k.TenantID, k.EnvironmentID); !errors.Is(err, ErrHarnessCapacity) { - t.Fatal("credential capacity not enforced", err) - } - firstRelease() - firstRelease() - next, _ := issueHarness(t, f, t.Context()) - if next == firstToken { - t.Fatal("replacement credential reused bearer") - } - nativePOST(t, f, k.EnvironmentID, "connect", firstToken, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) - f.registry.Close() - if _, _, err := f.registry.IssueHarnessCredential(t.Context(), k.TenantID, k.EnvironmentID); err == nil { - t.Fatal("closed registry issued a credential") - } - f.registry.mu.Lock() - defer f.registry.mu.Unlock() - if len(f.registry.harnessKeys) != 0 { - t.Fatal("closed registry retained credentials") - } -} - -func TestHarnessCredentialReleaseFencesGrantsAndPreservesSuccessor(t *testing.T) { - for _, cause := range []string{"release", "owner", "shutdown"} { - t.Run(cause, func(t *testing.T) { - f := newFixture(t) - owner, cancel := context.WithCancel(t.Context()) - defer cancel() - token, release := issueHarness(t, f, owner) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - grant := harnessMaterial(t, f, token) - harness := dial(t, grant.URL) - defer harness.Close() - validateGrant(t, f, validationBody(grant), true) - pending := harnessMaterial(t, f, token) - switch cause { - case "release": - release() - case "owner": - cancel() - case "shutdown": - f.registry.Close() - } - nativePOST(t, f, k.EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) - expectClosed(t, harness) - expectClosed(t, executor) - awaitLifecycle(t, f, k.EnvironmentID, reg.ExecutorRegistrationID, 2, false) - rejectSocket(t, pending.URL, 401) - if cause == "shutdown" { - return - } - nextToken, _ := issueHarness(t, f, t.Context()) - nextExecutor := dial(t, reg.URL) - defer nextExecutor.Close() - nextGrant := harnessMaterial(t, f, nextToken) - nextHarness := dial(t, nextGrant.URL) - defer nextHarness.Close() - release() - validateGrant(t, f, validationBody(grant), false) - validateGrant(t, f, validationBody(nextGrant), true) - relayBytes(t, nextHarness, nextExecutor, []byte("successor survives old release")) - }) - } -} - -type delayedHarnessOwnership struct { - EnvironmentStore - armed atomic.Bool - observed chan struct{} - resume chan struct{} -} - -func (s *delayedHarnessOwnership) GetEnvironment(ctx context.Context, tenant, environment string) (store.Environment, error) { - value, err := s.EnvironmentStore.GetEnvironment(ctx, tenant, environment) - if s.armed.Swap(false) { - close(s.observed) - select { - case <-s.resume: - case <-ctx.Done(): - } - } - return value, err -} - -func TestHarnessReleaseFencesAuthorizationAlreadyInFlight(t *testing.T) { - for _, operation := range []string{"connect", "attach", "validate"} { - t.Run(operation, func(t *testing.T) { - f := newFixture(t) - source := &delayedHarnessOwnership{EnvironmentStore: f.source, observed: make(chan struct{}), resume: make(chan struct{})} - f.registry.source = source - token, release := issueHarness(t, f, t.Context()) - reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - var grant ConnectResponse - if operation != "connect" { - grant = harnessMaterial(t, f, token) - } - if operation == "validate" { - harness := dial(t, grant.URL) - defer harness.Close() - } - source.armed.Store(true) - done := make(chan struct{}) - go func() { - defer close(done) - switch operation { - case "connect": - nativePOST(t, f, f.keys[0].EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 401, nil) - case "attach": - rejectSocket(t, grant.URL, 401) - case "validate": - validateGrant(t, f, validationBody(grant), false) - } - }() - <-source.observed - release() - close(source.resume) - <-done - f.registry.mu.Lock() - remaining := len(f.registry.registrations[f.keys[0].EnvironmentID].grants) - f.registry.mu.Unlock() - if remaining != 0 { - t.Fatal("in-flight authorization recreated a released grant") - } - }) - } -} diff --git a/services/agents-api/internal/executor/codex/harness_test.go b/services/agents-api/internal/executor/codex/harness_test.go deleted file mode 100644 index 74c7ecb1e..000000000 --- a/services/agents-api/internal/executor/codex/harness_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package codex - -import ( - "bytes" - "encoding/json" - "net/http" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/gorilla/websocket" -) - -func relayFixture(t *testing.T) (fixture, []string) { - t.Helper() - f := newFixture(t) - tokens := []string{} - for _, key := range f.keys { - token, release, err := f.registry.IssueHarnessCredential(t.Context(), key.TenantID, key.EnvironmentID) - if err != nil { - t.Fatal(err) - } - t.Cleanup(release) - tokens = append(tokens, token) - } - return f, tokens -} - -func nativePOST(t *testing.T, f fixture, environment, route, token string, body any, status int, result any) { - t.Helper() - data, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - req, err := http.NewRequest(http.MethodPost, f.server.URL+"/cloud/environment/"+environment+"/"+route, bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := f.server.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != status { - t.Fatalf("native %s status %d, expected %d", route, resp.StatusCode, status) - } - if result != nil { - if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - t.Fatal(err) - } - } -} -func harnessMaterial(t *testing.T, f fixture, token string) ConnectResponse { - t.Helper() - var result ConnectResponse - nativePOST(t, f, f.keys[0].EnvironmentID, "connect", token, ConnectRequest{nativeRequest().ExecutorPublicKey}, 200, &result) - if result.EnvironmentID != f.keys[0].EnvironmentID || result.ExecutorRegistrationID == "" || result.HarnessKeyAuthorization == "" || result.ExecutorPublicKey != nativeRequest().ExecutorPublicKey { - t.Fatal("invalid native connect material") - } - return result -} -func validationBody(grant ConnectResponse) ValidationRequest { - return ValidationRequest{grant.ExecutorRegistrationID, nativeRequest().ExecutorPublicKey, grant.HarnessKeyAuthorization} -} -func validateGrant(t *testing.T, f fixture, body ValidationRequest, want bool) { - t.Helper() - var result ValidationResponse - nativePOST(t, f, f.keys[0].EnvironmentID, "validate", f.tokens[0], body, 200, &result) - if result.Valid != want { - t.Fatalf("native authorization %v, expected %v", result.Valid, want) - } -} -func relayBytes(t *testing.T, source, target *websocket.Conn, data []byte) { - t.Helper() - if err := source.WriteMessage(websocket.BinaryMessage, data); err != nil { - t.Fatal(err) - } - _ = target.SetReadDeadline(time.Now().Add(2 * time.Second)) - kind, got, err := target.ReadMessage() - if err != nil || kind != websocket.BinaryMessage || !bytes.Equal(data, got) { - t.Fatal("opaque frame did not arrive unchanged") - } -} -func expectClosed(t *testing.T, c *websocket.Conn) { - t.Helper() - _ = c.SetReadDeadline(time.Now().Add(2 * heartbeatInterval)) - if _, _, err := c.ReadMessage(); err == nil { - t.Fatal("closed peer remained usable") - } -} - -func TestHarnessAuthorizationOpaqueRelayAndRefresh(t *testing.T) { - f, tokens := relayFixture(t) - k := f.keys[0] - request := ConnectRequest{nativeRequest().ExecutorPublicKey} - nativePOST(t, f, k.EnvironmentID, "connect", tokens[0], request, 503, nil) - for _, token := range []string{f.tokens[0], tokens[1], "caller-or-device"} { - nativePOST(t, f, k.EnvironmentID, "connect", token, request, 401, nil) - } - nativePOST(t, f, k.EnvironmentID, "register", tokens[0], nativeRequest(), 401, nil) - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - grant := harnessMaterial(t, f, tokens[0]) - validateGrant(t, f, validationBody(grant), false) - rejectSocket(t, strings.Replace(grant.URL, "/harness/", "/executor/", 1), 401) - harness := dial(t, grant.URL) - defer harness.Close() - rejectSocket(t, grant.URL, 401) - wrong := validationBody(grant) - wrong.HarnessKeyAuthorization = "wrong" - validateGrant(t, f, wrong, false) - wrong = validationBody(grant) - wrong.HarnessPublicKey.X25519 = nativeRequest().ExecutorPublicKey.X25519[0:1] + "Q" + nativeRequest().ExecutorPublicKey.X25519[2:] - validateGrant(t, f, wrong, false) - wrong = validationBody(grant) - wrong.ExecutorRegistrationID = uuid.NewString() - validateGrant(t, f, wrong, false) - nativePOST(t, f, k.EnvironmentID, "validate", tokens[0], validationBody(grant), 401, nil) - validateGrant(t, f, validationBody(grant), true) - validateGrant(t, f, validationBody(grant), false) - data := bytes.Repeat([]byte{0, 255, 81, 2}, maxRelayMessageSize/4) - relayBytes(t, harness, executor, data) - relayBytes(t, executor, harness, []byte{0, 0, 83, 254}) - refresh := harnessMaterial(t, f, tokens[0]) - if refresh.ExecutorRegistrationID != grant.ExecutorRegistrationID { - t.Fatal("refresh changed executor registration") - } - rejectSocket(t, refresh.URL, 409) - validateGrant(t, f, validationBody(refresh), false) - relayBytes(t, harness, executor, []byte("healthy after same-key refresh")) - f.registry.mu.Lock() - for _, g := range f.registry.registrations[k.EnvironmentID].grants { - g.expires = time.Now().Add(-time.Second) - } - f.registry.mu.Unlock() - rejectSocket(t, refresh.URL, 401) - relayBytes(t, executor, harness, []byte("expiry must not terminate an established pair")) - harness.Close() - expectClosed(t, executor) - awaitPresence(t, f, k.EnvironmentID, false) - rejectSocket(t, grant.URL, 401) - rejectSocket(t, refresh.URL, 401) - next := dial(t, reg.URL) - defer next.Close() - validateGrant(t, f, validationBody(grant), false) - fresh := harnessMaterial(t, f, tokens[0]) - resumed := dial(t, fresh.URL) - defer resumed.Close() - relayBytes(t, resumed, next, []byte("fresh generation")) -} - -func TestHarnessPairClosesOnReplacementDeletionOwnershipOrPeerLoss(t *testing.T) { - for _, cause := range []string{"executor", "replacement", "deleted", "lease"} { - t.Run(cause, func(t *testing.T) { - f, tokens := relayFixture(t) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - grant := harnessMaterial(t, f, tokens[0]) - harness := dial(t, grant.URL) - defer harness.Close() - switch cause { - case "executor": - executor.Close() - case "replacement": - replacement := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - next := dial(t, replacement.URL) - defer next.Close() - nextGrant := harnessMaterial(t, f, tokens[0]) - nextHarness := dial(t, nextGrant.URL) - defer nextHarness.Close() - relayBytes(t, nextHarness, next, []byte("replacement survives stale callbacks")) - validateGrant(t, f, validationBody(grant), false) - default: - f.source.mu.Lock() - if cause == "deleted" { - delete(f.source.values, k.EnvironmentID) - } else { - f.source.lost = true - } - f.source.mu.Unlock() - } - expectClosed(t, harness) - expectClosed(t, executor) - rejectSocket(t, grant.URL, 401) - }) - } -} - -func TestHarnessGrantsAreBoundedAndPruneExpiredPending(t *testing.T) { - f, tokens := relayFixture(t) - reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - for range maxHarnessGrants { - harnessMaterial(t, f, tokens[0]) - } - nativePOST(t, f, f.keys[0].EnvironmentID, "connect", tokens[0], ConnectRequest{nativeRequest().ExecutorPublicKey}, 429, nil) - f.registry.mu.Lock() - for _, grant := range f.registry.registrations[f.keys[0].EnvironmentID].grants { - grant.expires = time.Now().Add(-time.Second) - } - f.registry.mu.Unlock() - fresh := harnessMaterial(t, f, tokens[0]) - c := dial(t, fresh.URL) - c.Close() - expectClosed(t, executor) -} - -func TestHarnessPairRejectsTextOversizeAndBackpressure(t *testing.T) { - for _, fault := range []string{"text", "oversize", "backpressure"} { - t.Run(fault, func(t *testing.T) { - f, tokens := relayFixture(t) - reg := f.register(t, f.keys[0].EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - grant := harnessMaterial(t, f, tokens[0]) - harness := dial(t, grant.URL) - defer harness.Close() - switch fault { - case "text": - _ = harness.WriteMessage(websocket.TextMessage, []byte("invalid")) - case "oversize": - _ = harness.WriteMessage(websocket.BinaryMessage, make([]byte, maxRelayMessageSize+1)) - case "backpressure": - _ = harness.SetWriteDeadline(time.Now().Add(2 * relayWriteTimeout)) - data := make([]byte, maxRelayMessageSize) - for range 256 { - if err := harness.WriteMessage(websocket.BinaryMessage, data); err != nil { - break - } - } - } - expectClosed(t, harness) - // The stalled destination may retain already-forwarded frames in its kernel buffer. - awaitPresence(t, f, f.keys[0].EnvironmentID, false) - }) - } -} - -func TestHarnessCredentialCannotRegisterExecutor(t *testing.T) { - f, tokens := relayFixture(t) - f.register(t, f.keys[0].EnvironmentID, tokens[0], nativeRequest(), 401) -} diff --git a/services/agents-api/internal/executor/codex/lifecycle.go b/services/agents-api/internal/executor/codex/lifecycle.go deleted file mode 100644 index 19eaadcc3..000000000 --- a/services/agents-api/internal/executor/codex/lifecycle.go +++ /dev/null @@ -1,118 +0,0 @@ -package codex - -import ( - "context" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -const observationTimeout = 4 * time.Second - -type connectionObservation struct { - tenant, environment, generation string - revision int64 - connected bool -} - -// Capture and retain delivery while the socket mutation is still under the lock. -// The existing request or connection owns synchronous delivery outside that lock. -func (r *Registry) connectionObservationLocked(reg *registration, connected bool) *connectionObservation { - reg.revision++ - r.observations.Add(1) - return &connectionObservation{reg.key.TenantID, reg.key.EnvironmentID, reg.id, reg.revision, connected} -} - -func (r *Registry) replaceGeneration(reg *registration) error { - defer r.observations.Done() - ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) - err := r.replaceConnection(ctx, reg.key.TenantID, reg.key.EnvironmentID, reg.id) - cancel() - if err != nil { - r.lifecycleFailure("replace", reg.key.EnvironmentID, reg.id, err) - } - return err -} - -func (r *Registry) deliverObservation(observation *connectionObservation) error { - if observation == nil { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) - defer cancel() - return r.deliverObservationContext(ctx, observation) -} - -func (r *Registry) deliverObservationContext(ctx context.Context, observation *connectionObservation) error { - defer r.observations.Done() - err := r.observeConnection(ctx, observation.tenant, observation.environment, observation.generation, observation.revision, observation.connected) - if err != nil { - r.lifecycleFailure("observe", observation.environment, observation.generation, err) - } - return err -} - -func (r *Registry) lifecycleFailure(operation, environment, generation string, err error) { - log.Bg().Error("native executor connection lifecycle write failed", "operation", operation, "environment_id", environment) - if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrInvalidInput) { - // A deleted or terminal target cannot authorize a connection, but does not disable peers. - r.mu.Lock() - if reg := r.registrations[environment]; reg != nil && (operation == "replace" || reg.id == generation) { - delete(r.registrations, environment) - r.closeConnectionLocked(reg, reg.socket) - } - r.mu.Unlock() - return - } - r.mu.Lock() - if r.lifecycleErr == nil { - r.lifecycleErr = err - } - r.mu.Unlock() - // Do not wait here: this call can itself own an observation being drained. - r.closeConnections() -} - -// LifecycleError reports the first persistence failure that closed the registry. -func (r *Registry) LifecycleError() error { - r.mu.Lock() - defer r.mu.Unlock() - return r.lifecycleErr -} - -// Close stops admission and drains accepted lifecycle writes before returning. -// The execution owner must retain its lease until Close completes. -func (r *Registry) Close() { - r.closeConnections() - r.observations.Wait() -} - -func (r *Registry) closeConnections() { - r.mu.Lock() - if r.closed { - r.mu.Unlock() - return - } - r.closed = true - for hash, credential := range r.harnessKeys { - credential.stop() - delete(r.harnessKeys, hash) - } - var observations []*connectionObservation - for environment, reg := range r.registrations { - if observation := r.closeConnectionLocked(reg, reg.socket); observation != nil { - observations = append(observations, observation) - } - delete(r.registrations, environment) - } - r.mu.Unlock() - // One budget covers the complete shutdown batch, including callbacks waiting - // for the leased writer. Expired callbacks still settle their delivery count. - ctx, cancel := context.WithTimeout(context.Background(), observationTimeout) - defer cancel() - for _, observation := range observations { - _ = r.deliverObservationContext(ctx, observation) - } -} diff --git a/services/agents-api/internal/executor/codex/lifecycle_test.go b/services/agents-api/internal/executor/codex/lifecycle_test.go deleted file mode 100644 index c82779949..000000000 --- a/services/agents-api/internal/executor/codex/lifecycle_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package codex - -import ( - "context" - "errors" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -type lifecycleFixture struct { - mu sync.Mutex - states map[string]connectionObservation - calls []connectionObservation -} - -func (f *lifecycleFixture) replace(ctx context.Context, tenant, environment, generation string) error { - if err := ctx.Err(); err != nil { - return err - } - f.mu.Lock() - defer f.mu.Unlock() - if f.states[environment].generation != generation { - value := connectionObservation{tenant: tenant, environment: environment, generation: generation} - f.states[environment] = value - f.calls = append(f.calls, value) - } - return nil -} - -func (f *lifecycleFixture) observe(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - if err := ctx.Err(); err != nil { - return err - } - f.mu.Lock() - defer f.mu.Unlock() - value := connectionObservation{tenant, environment, generation, revision, connected} - f.calls = append(f.calls, value) - previous := f.states[environment] - if previous.generation == generation && previous.revision < revision { - f.states[environment] = value - } - return nil -} - -func (f *lifecycleFixture) state(environment string) connectionObservation { - f.mu.Lock() - defer f.mu.Unlock() - return f.states[environment] -} - -func awaitLifecycle(t *testing.T, f fixture, environment, generation string, revision int64, connected bool) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - value := f.lifecycle.state(environment) - if value.generation == generation && value.revision == revision && value.connected == connected { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatal("lifecycle did not converge", f.lifecycle.state(environment)) -} - -func awaitLifecycleSignal(t *testing.T, done <-chan struct{}) { - t.Helper() - select { - case <-done: - case <-time.After(3 * time.Second): - t.Fatal("lifecycle operation did not complete") - } -} - -func TestConnectionLifecycleRequiresBothCallbacks(t *testing.T) { - f := newFixture(t) - config := Config{Store: f.source, CheckOwnership: f.source.owner, PublicURL: f.server.URL} - if _, err := New(config); err == nil { - t.Fatal("missing lifecycle callbacks accepted") - } - config.ReplaceConnection = f.lifecycle.replace - if _, err := New(config); err == nil { - t.Fatal("missing observation callback accepted") - } - config.ReplaceConnection = nil - config.ObserveConnection = f.lifecycle.observe - if _, err := New(config); err == nil { - t.Fatal("missing replacement callback accepted") - } -} - -func TestConnectionLifecycleTracksSocketReconnectAndShutdown(t *testing.T) { - f := newFixture(t) - key := f.keys[0] - reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 0, false) - executor := dial(t, reg.URL) - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 1, true) - executor.Close() - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) - executor = dial(t, reg.URL) - defer executor.Close() - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 3, true) - f.registry.Close() - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 4, false) - f.lifecycle.mu.Lock() - defer f.lifecycle.mu.Unlock() - if len(f.lifecycle.calls) != 5 { - t.Fatal("socket cleanup emitted duplicate observations", f.lifecycle.calls) - } -} - -func TestConnectionLifecycleReplacementFencesDelayedLoss(t *testing.T) { - entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) - var once, releaseOnce sync.Once - f := newFixture(t, func(config *Config) { - observe := config.ObserveConnection - config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - blocked := false - if !connected { - once.Do(func() { - blocked = true - close(entered) - select { - case <-release: - case <-ctx.Done(): - } - }) - } - err := observe(ctx, tenant, environment, generation, revision, connected) - if blocked { - close(finished) - } - return err - } - }) - t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) - key := f.keys[0] - first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, first.URL) - awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) - executor.Close() - awaitLifecycleSignal(t, entered) - next := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - successor := dial(t, next.URL) - defer successor.Close() - awaitLifecycle(t, f, key.EnvironmentID, next.ExecutorRegistrationID, 1, true) - releaseOnce.Do(func() { close(release) }) - awaitLifecycleSignal(t, finished) - awaitLifecycle(t, f, key.EnvironmentID, next.ExecutorRegistrationID, 1, true) - awaitPresence(t, f, key.EnvironmentID, true) -} - -func TestConnectionLifecycleCloseDrainsOutOfOrderWrites(t *testing.T) { - entered, release := make(chan struct{}), make(chan struct{}) - var releaseOnce sync.Once - f := newFixture(t, func(config *Config) { - observe := config.ObserveConnection - config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - if connected { - close(entered) - select { - case <-release: - case <-ctx.Done(): - } - } - return observe(ctx, tenant, environment, generation, revision, connected) - } - }) - t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) - key := f.keys[0] - reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - awaitLifecycleSignal(t, entered) - closed := make(chan struct{}) - go func() { f.registry.Close(); close(closed) }() - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) - select { - case <-closed: - t.Fatal("Close returned while an accepted observation was still in flight") - default: - } - releaseOnce.Do(func() { close(release) }) - awaitLifecycleSignal(t, closed) - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 2, false) - expectClosed(t, executor) -} - -func TestConnectionLifecycleReplacementWritesOutsideRegistryMutex(t *testing.T) { - entered, release := make(chan struct{}), make(chan struct{}) - var armed atomic.Bool - var releaseOnce sync.Once - f := newFixture(t, func(config *Config) { - replace := config.ReplaceConnection - config.ReplaceConnection = func(ctx context.Context, tenant, environment, generation string) error { - if armed.Swap(false) { - close(entered) - select { - case <-release: - case <-ctx.Done(): - } - } - return replace(ctx, tenant, environment, generation) - } - }) - t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) - key := f.keys[0] - first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, first.URL) - defer executor.Close() - awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) - armed.Store(true) - finished := make(chan struct{}) - go func() { - defer close(finished) - f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 503) - }() - awaitLifecycleSignal(t, entered) - closed := make(chan struct{}) - go func() { f.registry.Close(); close(closed) }() - awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 2, false) - select { - case <-closed: - t.Fatal("Close did not retain the accepted replacement") - default: - } - releaseOnce.Do(func() { close(release) }) - awaitLifecycleSignal(t, finished) - awaitLifecycleSignal(t, closed) - f.registry.mu.Lock() - defer f.registry.mu.Unlock() - if len(f.registry.registrations) != 0 { - t.Fatal("late replacement reopened a closed registry") - } -} - -func TestConnectionLifecycleFailureRetiresTargetOrClosesRegistry(t *testing.T) { - for _, failure := range []error{store.ErrNotFound, store.ErrInvalidInput, errors.New("observation storage unavailable")} { - t.Run(failure.Error(), func(t *testing.T) { - var armed atomic.Bool - failed := make(chan struct{}) - f := newFixture(t, func(config *Config) { - observe := config.ObserveConnection - config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - if connected && armed.Swap(false) { - defer close(failed) - return failure - } - return observe(ctx, tenant, environment, generation, revision, connected) - } - }) - other := f.keys[1] - first := f.register(t, other.EnvironmentID, f.tokens[1], nativeRequest(), 200) - healthy := dial(t, first.URL) - defer healthy.Close() - awaitLifecycle(t, f, other.EnvironmentID, first.ExecutorRegistrationID, 1, true) - key := f.keys[0] - reg := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - armed.Store(true) - executor := dial(t, reg.URL) - defer executor.Close() - awaitLifecycleSignal(t, failed) - expectClosed(t, executor) - wantClosed := !errors.Is(failure, store.ErrNotFound) && !errors.Is(failure, store.ErrInvalidInput) - f.registry.mu.Lock() - closed := f.registry.closed - _, retained := f.registry.registrations[key.EnvironmentID] - f.registry.mu.Unlock() - if closed != wantClosed || retained { - t.Fatal("lifecycle failure did not close the expected scope", closed, retained) - } - if wantClosed { - expectClosed(t, healthy) - if !errors.Is(f.registry.LifecycleError(), failure) { - t.Fatal("persistence failure was not retained") - } - } else { - connected, err := f.registry.Connected(t.Context(), other.TenantID, other.EnvironmentID) - if err != nil || !connected || f.registry.LifecycleError() != nil { - t.Fatal("target retirement disabled another Environment", err) - } - } - f.registry.Close() - }) - } -} - -func TestConnectionLifecycleReplaceFailureDoesNotPublish(t *testing.T) { - for _, failure := range []error{store.ErrNotFound, store.ErrInvalidInput, errors.New("replacement storage unavailable")} { - t.Run(failure.Error(), func(t *testing.T) { - var armed atomic.Bool - f := newFixture(t, func(config *Config) { - replace := config.ReplaceConnection - config.ReplaceConnection = func(ctx context.Context, tenant, environment, generation string) error { - if armed.Swap(false) { - return failure - } - return replace(ctx, tenant, environment, generation) - } - }) - key := f.keys[0] - first := f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 200) - executor := dial(t, first.URL) - defer executor.Close() - awaitLifecycle(t, f, key.EnvironmentID, first.ExecutorRegistrationID, 1, true) - armed.Store(true) - f.register(t, key.EnvironmentID, f.tokens[0], nativeRequest(), 503) - expectClosed(t, executor) - f.registry.mu.Lock() - _, retained := f.registry.registrations[key.EnvironmentID] - f.registry.mu.Unlock() - if retained { - t.Fatal("failed replacement left its predecessor or an unpublished successor usable") - } - f.registry.Close() - }) - } -} - -func TestConnectionLifecycleShutdownSharesOneDeadline(t *testing.T) { - var mu sync.Mutex - var deadlines []time.Time - f := newFixture(t, func(config *Config) { - observe := config.ObserveConnection - config.ObserveConnection = func(ctx context.Context, tenant, environment, generation string, revision int64, connected bool) error { - if !connected { - deadline, ok := ctx.Deadline() - if !ok { - t.Error("shutdown observation has no deadline") - } - mu.Lock() - deadlines = append(deadlines, deadline) - mu.Unlock() - } - return observe(ctx, tenant, environment, generation, revision, connected) - } - }) - for i, key := range f.keys { - reg := f.register(t, key.EnvironmentID, f.tokens[i], nativeRequest(), 200) - executor := dial(t, reg.URL) - defer executor.Close() - awaitLifecycle(t, f, key.EnvironmentID, reg.ExecutorRegistrationID, 1, true) - } - f.registry.Close() - mu.Lock() - defer mu.Unlock() - if len(deadlines) != 2 || !deadlines[0].Equal(deadlines[1]) { - t.Fatal("shutdown granted a fresh timeout to each Environment", deadlines) - } -} diff --git a/services/agents-api/internal/executor/codex/messages.go b/services/agents-api/internal/executor/codex/messages.go deleted file mode 100644 index 436a6d749..000000000 --- a/services/agents-api/internal/executor/codex/messages.go +++ /dev/null @@ -1,86 +0,0 @@ -package codex - -import ( - "encoding/base64" - "encoding/json" - "io" - "net/http" -) - -const securityProfile = "noise_hybrid_ik_v1" -const noiseSuite = "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256" - -// PublicKey follows the pinned native exec-server NoiseChannelPublicKey wire type. -type PublicKey struct { - Suite string `json:"suite"` - X25519 string `json:"x25519_public_key"` - MLKEM768 string `json:"mlkem768_public_key"` -} - -func (k PublicKey) valid() bool { - dh, err := base64.StdEncoding.Strict().DecodeString(k.X25519) - kem, kemErr := base64.StdEncoding.Strict().DecodeString(k.MLKEM768) - return k.Suite == noiseSuite && err == nil && len(dh) == 32 && kemErr == nil && len(kem) == 1184 -} - -type RegistrationRequest struct { - SecurityProfile string `json:"security_profile"` - ExecutorPublicKey PublicKey `json:"executor_public_key"` -} - -type RegistrationResponse struct { - EnvironmentID string `json:"environment_id"` - URL string `json:"url"` - SecurityProfile string `json:"security_profile"` - ExecutorRegistrationID string `json:"executor_registration_id"` -} - -type RegistryError struct { - Error ErrorDetail `json:"error"` -} -type ErrorDetail struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} - -func writeError(w http.ResponseWriter, status int) { - writeJSON(w, status, RegistryError{Error: ErrorDetail{Code: "executor_registry_error", Message: http.StatusText(status)}}) -} - -type ConnectRequest struct { - HarnessPublicKey PublicKey `json:"harness_public_key"` -} -type ConnectResponse struct { - RegistrationResponse - ExecutorPublicKey PublicKey `json:"executor_public_key"` - HarnessKeyAuthorization string `json:"harness_key_authorization"` -} -type ValidationRequest struct { - ExecutorRegistrationID string `json:"executor_registration_id"` - HarnessPublicKey PublicKey `json:"harness_public_key"` - HarnessKeyAuthorization string `json:"harness_key_authorization"` -} -type ValidationResponse struct { - Valid bool `json:"valid"` -} - -func decodeRequest(w http.ResponseWriter, req *http.Request, body any) bool { - decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, 8*1024)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(body); err != nil { - writeError(w, http.StatusBadRequest) - return false - } - if err := decoder.Decode(new(any)); err != io.EOF { - writeError(w, http.StatusBadRequest) - return false - } - return true -} diff --git a/services/agents-api/internal/executor/codex/registry.go b/services/agents-api/internal/executor/codex/registry.go deleted file mode 100644 index 01b024680..000000000 --- a/services/agents-api/internal/executor/codex/registry.go +++ /dev/null @@ -1,188 +0,0 @@ -package codex - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "errors" - "net/http" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -const ticketLifetime = 5 * time.Minute - -// Registry owns replaceable connections, never durable public readiness. -type Registry struct { - source EnvironmentStore - checkOwnership func(context.Context) error - replaceConnection func(context.Context, string, string, string) error - observeConnection func(context.Context, string, string, string, int64, bool) error - observations sync.WaitGroup - lifecycleErr error - publicWS string - harnessKeys map[[32]byte]*harnessCredential - registrationMu sync.Mutex - mu sync.Mutex - closed bool - registrations map[string]*registration -} - -type registration struct { - id string - key ScopedKey - publicKey PublicKey - ticket [32]byte - expires time.Time - socket *connection - grants map[[32]byte]*harnessGrant - revision int64 -} - -func New(c Config) (*Registry, error) { - url, err := validateConfig(c) - if err != nil { - return nil, err - } - return &Registry{harnessKeys: make(map[[32]byte]*harnessCredential), source: c.Store, checkOwnership: c.CheckOwnership, - replaceConnection: c.ReplaceConnection, observeConnection: c.ObserveConnection, - publicWS: url, registrations: make(map[string]*registration)}, nil -} - -// PublicURL returns the validated origin used for native executor registration. -func (r *Registry) PublicURL() string { - if origin, ok := strings.CutPrefix(r.publicWS, "wss://"); ok { - return "https://" + origin - } - return "http://" + strings.TrimPrefix(r.publicWS, "ws://") -} - -func (r *Registry) Handler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("POST /cloud/environment/{environment}/register", r.register) - mux.HandleFunc("GET /cloud/environment/{environment}/executor/{registration}", r.connectExecutor) - mux.HandleFunc("POST /cloud/environment/{environment}/connect", r.connect) - mux.HandleFunc("POST /cloud/environment/{environment}/validate", r.validate) - mux.HandleFunc("GET /cloud/environment/{environment}/harness/{registration}", r.connectHarness) - return mux -} - -func (r *Registry) authorized(ctx context.Context, key ScopedKey) error { - // Client disconnects must not cancel a query on the shared execution lease. - ownerCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second) - defer cancel() - if err := r.checkOwnership(ownerCtx); err != nil { - r.closeConnections() - return err - } - _, err := r.source.GetEnvironment(ctx, key.TenantID, key.EnvironmentID) - return err -} - -func (r *Registry) check(w http.ResponseWriter, req *http.Request, key ScopedKey) bool { - ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) - defer cancel() - err := r.authorized(ctx, key) - if err == nil { - return true - } - status := http.StatusServiceUnavailable - if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrInvalidInput) { - status = http.StatusNotFound - } - writeError(w, status) - return false -} - -// @Summary Register a native executor (internal transport, not public Environment readiness) -// @Tags Native executor registry -// @Accept json -// @Produce json -// @Param environment path string true "Environment ID" -// @Param request body RegistrationRequest true "Native executor key" -// @Success 200 {object} RegistrationResponse -// @Failure 400,401,404,503 {object} RegistryError -// @Router /cloud/environment/{environment}/register [post] -func (r *Registry) register(w http.ResponseWriter, req *http.Request) { - environment := req.PathValue("environment") - key, ok := r.executorCredential(w, req, environment) - if !ok { - return - } - if !r.check(w, req, key) { - return - } - var body RegistrationRequest - if !decodeRequest(w, req, &body) { - return - } - if body.SecurityProfile != securityProfile || !body.ExecutorPublicKey.valid() { - writeError(w, http.StatusBadRequest) - return - } - ticket, err := capability() - if err != nil { - writeError(w, http.StatusServiceUnavailable) - return - } - next := ®istration{id: uuid.NewString(), key: key, publicKey: body.ExecutorPublicKey, ticket: sha256.Sum256([]byte(ticket)), expires: time.Now().Add(ticketLifetime), grants: make(map[[32]byte]*harnessGrant)} - // Order credential observation and replacement without blocking relay heartbeats on a database read. - r.registrationMu.Lock() - if !r.checkCurrentExecutor(w, req, key) { - r.registrationMu.Unlock() - return - } - r.mu.Lock() - if r.closed { - r.mu.Unlock() - r.registrationMu.Unlock() - writeError(w, http.StatusServiceUnavailable) - return - } - r.observations.Add(1) - r.mu.Unlock() - if err := r.replaceGeneration(next); err != nil { - r.registrationMu.Unlock() - writeError(w, http.StatusServiceUnavailable) - return - } - r.mu.Lock() - if r.closed { - r.mu.Unlock() - r.registrationMu.Unlock() - writeError(w, http.StatusServiceUnavailable) - return - } - previous := r.registrations[environment] - r.registrations[environment] = next - if previous != nil { - r.closeConnectionLocked(previous, previous.socket) - } - r.mu.Unlock() - r.registrationMu.Unlock() - writeJSON(w, http.StatusOK, RegistrationResponse{EnvironmentID: environment, ExecutorRegistrationID: next.id, SecurityProfile: securityProfile, URL: r.publicWS + "/cloud/environment/" + environment + "/executor/" + next.id + "?ticket=" + ticket}) -} - -// Connected reports a current authenticated socket, not harness readiness or filesystem isolation. -func (r *Registry) Connected(ctx context.Context, tenant, environment string) (bool, error) { - if err := r.authorized(ctx, ScopedKey{TenantID: tenant, EnvironmentID: environment}); err != nil { - return false, err - } - r.mu.Lock() - defer r.mu.Unlock() - reg := r.registrations[environment] - return !r.closed && reg != nil && reg.socket != nil, nil -} - -func capability() (string, error) { - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(secret), nil -} diff --git a/services/agents-api/internal/executor/codex/registry_test.go b/services/agents-api/internal/executor/codex/registry_test.go deleted file mode 100644 index 2b9e24c51..000000000 --- a/services/agents-api/internal/executor/codex/registry_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package codex - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/gorilla/websocket" -) - -type environmentFixture struct { - mu sync.Mutex - values map[string]string - keys map[string]string - lost bool -} - -func (s *environmentFixture) GetEnvironment(ctx context.Context, tenant, id string) (store.Environment, error) { - if err := ctx.Err(); err != nil { - return store.Environment{}, err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.values[id] != tenant { - return store.Environment{}, store.ErrNotFound - } - return store.Environment{ID: id, TenantID: tenant}, nil -} -func (s *environmentFixture) AuthenticateEnvironmentExecutor(ctx context.Context, id, digest string) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.values[id] == "" || s.keys[id] != digest { - return "", store.ErrNotFound - } - return s.values[id], nil -} - -func (s *environmentFixture) owner(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.lost { - return errors.New("lost") - } - return nil -} - -func digest(token string) string { - sum := sha256.Sum256([]byte(token)) - return hex.EncodeToString(sum[:]) -} -func nativeRequest() RegistrationRequest { - return RegistrationRequest{SecurityProfile: securityProfile, ExecutorPublicKey: PublicKey{Suite: noiseSuite, X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))}} -} - -type fixture struct { - registry *Registry - server *httptest.Server - source *environmentFixture - keys []ScopedKey - tokens []string - lifecycle *lifecycleFixture -} - -func newFixture(t *testing.T, configure ...func(*Config)) fixture { - t.Helper() - source := &environmentFixture{values: map[string]string{}, keys: map[string]string{}} - keys, tokens := []ScopedKey{}, []string{} - for range 2 { - token := uuid.NewString() - k := ScopedKey{TokenSHA256: digest(token), TenantID: uuid.NewString(), EnvironmentID: uuid.NewString()} - keys = append(keys, k) - tokens = append(tokens, token) - source.values[k.EnvironmentID] = k.TenantID - source.keys[k.EnvironmentID] = k.TokenSHA256 - } - server := httptest.NewUnstartedServer(nil) - lifecycle := &lifecycleFixture{states: make(map[string]connectionObservation)} - config := Config{Store: source, CheckOwnership: source.owner, ReplaceConnection: lifecycle.replace, - ObserveConnection: lifecycle.observe, PublicURL: "http://" + server.Listener.Addr().String()} - for _, option := range configure { - option(&config) - } - registry, err := New(config) - if err != nil { - t.Fatal(err) - } - server.Config.Handler = registry.Handler() - server.Start() - t.Cleanup(func() { registry.Close(); server.Close() }) - return fixture{registry, server, source, keys, tokens, lifecycle} -} -func (f fixture) register(t *testing.T, environment, token string, body any, expected int) RegistrationResponse { - t.Helper() - encoded, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - req, err := http.NewRequest(http.MethodPost, f.server.URL+"/cloud/environment/"+environment+"/register", bytes.NewReader(encoded)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := f.server.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != expected { - t.Fatalf("registration status %d, expected %d", resp.StatusCode, expected) - } - var result RegistrationResponse - if expected == 200 { - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - t.Fatal(err) - } - if result.EnvironmentID != environment || result.ExecutorRegistrationID == "" || result.SecurityProfile != securityProfile { - t.Fatal("invalid native response") - } - } - return result -} -func dial(t *testing.T, url string) *websocket.Conn { - t.Helper() - c, resp, err := websocket.DefaultDialer.Dial(url, nil) - if err != nil { - if resp != nil { - t.Fatalf("socket rejected: %d", resp.StatusCode) - } - t.Fatal("socket failed") - } - return c -} -func rejectSocket(t *testing.T, url string, status int) { - t.Helper() - c, resp, err := websocket.DefaultDialer.Dial(url, nil) - if c != nil { - c.Close() - } - if resp != nil { - defer resp.Body.Close() - } - if err == nil || resp == nil || resp.StatusCode != status { - t.Fatal("unexpected socket authorization outcome") - } -} -func awaitPresence(t *testing.T, f fixture, id string, want bool) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - connected, err := f.registry.Connected(t.Context(), f.keys[0].TenantID, id) - if err == nil && connected == want { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatal("presence did not converge") -} - -func TestRegistrationScopesAndRealSocketReplacement(t *testing.T) { - f := newFixture(t) - first := f.keys[0] - f.register(t, first.EnvironmentID, f.tokens[1], nativeRequest(), 401) - f.register(t, first.EnvironmentID, "caller-or-device-key", nativeRequest(), 401) - f.register(t, uuid.NewString(), f.tokens[0], nativeRequest(), 401) - bad := nativeRequest() - bad.ExecutorPublicKey.Suite = "wrong" - f.register(t, first.EnvironmentID, f.tokens[0], bad, 400) - reg := f.register(t, first.EnvironmentID, f.tokens[0], nativeRequest(), 200) - rejectSocket(t, reg.URL+"invalid", 401) - c := dial(t, reg.URL) - defer c.Close() - awaitPresence(t, f, first.EnvironmentID, true) - rejectSocket(t, reg.URL, 409) - replacement := f.register(t, first.EnvironmentID, f.tokens[0], nativeRequest(), 200) - rejectSocket(t, reg.URL, 401) - next := dial(t, replacement.URL) - defer next.Close() - awaitPresence(t, f, first.EnvironmentID, true) - _ = c.SetReadDeadline(time.Now().Add(time.Second)) - if _, _, err := c.ReadMessage(); err == nil { - t.Fatal("replaced socket survived") - } - awaitPresence(t, f, first.EnvironmentID, true) - next.Close() - awaitPresence(t, f, first.EnvironmentID, false) - reconnect := dial(t, replacement.URL) - reconnect.Close() - awaitPresence(t, f, first.EnvironmentID, false) - if _, err := f.registry.Connected(t.Context(), f.keys[1].TenantID, first.EnvironmentID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("foreign presence visible") - } -} - -func TestExpiredTicketAndClosedRegistryRejectAccess(t *testing.T) { - f := newFixture(t) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - f.registry.mu.Lock() - f.registry.registrations[k.EnvironmentID].expires = time.Now().Add(-time.Second) - f.registry.mu.Unlock() - rejectSocket(t, reg.URL, 401) - f.registry.Close() - rejectSocket(t, reg.URL, 401) - f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 503) -} - -func TestOwnershipLossAndDeletionClosePresence(t *testing.T) { - for _, loss := range []string{"deleted", "lease"} { - t.Run(loss, func(t *testing.T) { - f := newFixture(t) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - c := dial(t, reg.URL) - defer c.Close() - f.source.mu.Lock() - if loss == "deleted" { - delete(f.source.values, k.EnvironmentID) - } else { - f.source.lost = true - } - f.source.mu.Unlock() - _ = c.SetReadDeadline(time.Now().Add(2 * heartbeatInterval)) - if _, _, err := c.ReadMessage(); err == nil { - t.Fatal("unauthorized socket survived") - } - expected := 401 - if loss == "lease" { - expected = 503 - } - f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), expected) - }) - } -} - -func TestExecutorPresenceHasNoCommandRelay(t *testing.T) { - f := newFixture(t) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - c := dial(t, reg.URL) - defer c.Close() - if err := c.WriteMessage(websocket.BinaryMessage, []byte("no command relay")); err != nil { - t.Fatal(err) - } - _ = c.SetReadDeadline(time.Now().Add(time.Second)) - _, _, err := c.ReadMessage() - if !websocket.IsCloseError(err, websocket.CloseUnsupportedData) { - t.Fatal("command traffic accepted") - } - awaitPresence(t, f, k.EnvironmentID, false) -} - -func TestExecutorConfigRejectsUnsafeOrAmbiguousBindings(t *testing.T) { - f := newFixture(t) - base := Config{Store: f.source, CheckOwnership: f.source.owner, ReplaceConnection: f.lifecycle.replace, - ObserveConnection: f.lifecycle.observe, PublicURL: f.server.URL} - for _, url := range []string{"http://executor.example", "https://user:secret@example", "https://example/path", "https://example?token=x", "ws://localhost"} { - c := base - c.PublicURL = url - if _, err := New(c); err == nil { - t.Fatal("unsafe URL accepted") - } - } -} - -func TestRegistrationCallerCancellationKeepsOtherConnections(t *testing.T) { - f := newFixture(t) - k := f.keys[0] - reg := f.register(t, k.EnvironmentID, f.tokens[0], nativeRequest(), 200) - c := dial(t, reg.URL) - defer c.Close() - awaitPresence(t, f, k.EnvironmentID, true) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, err := f.registry.Connected(ctx, k.TenantID, k.EnvironmentID); !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - awaitPresence(t, f, k.EnvironmentID, true) -} diff --git a/services/agents-api/internal/executor/codex/socket.go b/services/agents-api/internal/executor/codex/socket.go deleted file mode 100644 index b1e367cca..000000000 --- a/services/agents-api/internal/executor/codex/socket.go +++ /dev/null @@ -1,150 +0,0 @@ -package codex - -import ( - "context" - "crypto/sha256" - "crypto/subtle" - "net/http" - "time" - - "github.com/gorilla/websocket" -) - -const heartbeatInterval = 5 * time.Second -const maxRelayMessageSize = 256 * 1024 -const relayWriteTimeout = 5 * time.Second - -var nativeUpgrader = websocket.Upgrader{HandshakeTimeout: 5 * time.Second, ReadBufferSize: 1024, WriteBufferSize: 1024} - -type connection struct { - socket *websocket.Conn - peer *connection -} - -// @Summary Attach an executor using a registration-scoped connection capability -// @Tags Native executor registry -// @Param environment path string true "Environment ID" -// @Param registration path string true "Registration ID" -// @Param ticket query string true "Private connection capability" -// @Success 101 {string} string "WebSocket upgrade" -// @Failure 401,404,409,503 {object} RegistryError -// @Router /cloud/environment/{environment}/executor/{registration} [get] -func (r *Registry) connectExecutor(w http.ResponseWriter, req *http.Request) { - environment, id := req.PathValue("environment"), req.PathValue("registration") - ticket := sha256.Sum256([]byte(req.URL.Query().Get("ticket"))) - r.mu.Lock() - reg := r.registrations[environment] - valid := !r.closed && reg != nil && reg.id == id && time.Now().Before(reg.expires) && subtle.ConstantTimeCompare(ticket[:], reg.ticket[:]) == 1 - r.mu.Unlock() - if !valid { - writeError(w, http.StatusUnauthorized) - return - } - if !r.check(w, req, reg.key) || !r.checkCurrentExecutor(w, req, reg.key) { - return - } - r.mu.Lock() - if r.closed || r.registrations[environment] != reg || !time.Now().Before(reg.expires) || reg.socket != nil { - r.mu.Unlock() - writeError(w, http.StatusConflict) - return - } - socket, err := nativeUpgrader.Upgrade(w, req, nil) - if err != nil { - r.mu.Unlock() - return - } - c := &connection{socket: socket} - reg.socket = c - observation := r.connectionObservationLocked(reg, true) - r.mu.Unlock() - if err := r.deliverObservation(observation); err != nil { - return - } - r.serveConnection(environment, reg, c) -} - -func (r *Registry) serveConnection(environment string, reg *registration, c *connection) { - defer func() { - r.mu.Lock() - observation := r.closeConnectionLocked(reg, c) - r.mu.Unlock() - _ = r.deliverObservation(observation) - }() - socket := c.socket - socket.SetReadLimit(maxRelayMessageSize) - _ = socket.SetReadDeadline(time.Now().Add(3 * heartbeatInterval)) - socket.SetPongHandler(func(string) error { return socket.SetReadDeadline(time.Now().Add(3 * heartbeatInterval)) }) - done := make(chan struct{}) - defer close(done) - go r.heartbeat(environment, reg, c, done) - for { - kind, data, err := socket.ReadMessage() - if err != nil { - return - } - r.mu.Lock() - peer := c.peer - current := !r.closed && r.registrations[environment] == reg && (reg.socket == c || reg.socket == peer) - r.mu.Unlock() - if kind != websocket.BinaryMessage || peer == nil { - _ = socket.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseUnsupportedData, "binary paired relay required"), time.Now().Add(time.Second)) - return - } - if !current { - return - } - // Each peer has exactly one data writer. Blocking bounds buffering to one native frame per direction. - if err := peer.socket.SetWriteDeadline(time.Now().Add(relayWriteTimeout)); err != nil { - return - } - if err := peer.socket.WriteMessage(websocket.BinaryMessage, data); err != nil { - return - } - } -} - -func (r *Registry) closeConnectionLocked(reg *registration, c *connection) *connectionObservation { - if c == nil { - return nil - } - _ = c.socket.Close() - if c.peer != nil { - _ = c.peer.socket.Close() - } - // Close both physical peers so the native executor detaches its virtual Session before reconnecting. - if reg.socket != nil && (reg.socket == c || reg.socket == c.peer) { - reg.socket = nil - clear(reg.grants) - if r.registrations[reg.key.EnvironmentID] == reg { - return r.connectionObservationLocked(reg, false) - } - } - return nil -} - -func (r *Registry) heartbeat(environment string, reg *registration, c *connection, done <-chan struct{}) { - ticker := time.NewTicker(heartbeatInterval) - defer ticker.Stop() - for { - select { - case <-done: - return - case <-ticker.C: - ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) - err := r.executorAuthorized(ctx, reg.key) - cancel() - r.mu.Lock() - current := !r.closed && r.registrations[environment] == reg && (reg.socket == c || (reg.socket != nil && reg.socket.peer == c)) - r.mu.Unlock() - if err != nil || !current { - _ = c.socket.Close() - return - } - if err := c.socket.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil { - _ = c.socket.Close() - return - } - } - } -} diff --git a/services/agents-api/internal/runtime/gateway.go b/services/agents-api/internal/runtime/gateway.go index 246de1c66..e6de36d37 100644 --- a/services/agents-api/internal/runtime/gateway.go +++ b/services/agents-api/internal/runtime/gateway.go @@ -16,8 +16,8 @@ type DeviceStore interface { gateway.HeartbeatTouch } -// NewGateway exposes the existing internal daemon protocol. It does not implement -// the public Agents API self_hosted executor contract or grant Session API access. +// NewGateway serves the V1 daemon executor transport for both managed and +// user-managed Runtime. Its credentials never grant public Session API access. func NewGateway(s DeviceStore, publicWSURL string) (http.Handler, *gateway.Registry, error) { u, err := url.Parse(publicWSURL) if err != nil || s == nil || (u.Scheme != "ws" && u.Scheme != "wss") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/api/v1/agent-daemon/ws" { diff --git a/services/agents-api/internal/runtimeenrollment/enrollment.go b/services/agents-api/internal/runtimeenrollment/enrollment.go new file mode 100644 index 000000000..a220e6a3b --- /dev/null +++ b/services/agents-api/internal/runtimeenrollment/enrollment.go @@ -0,0 +1,65 @@ +package runtimeenrollment + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type EnrollmentStore interface { + EnrollRuntime(context.Context, string, string) (store.RuntimeEnrollment, error) +} + +// EnrollmentHandler is part of our daemon connection contract, not an upstream +// Agents resource. It grants no Session API access and never issues another key. +func EnrollmentHandler(s EnrollmentStore) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fail := func(status int) { http.Error(w, http.StatusText(status), status) } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + fail(http.StatusMethodNotAllowed) + return + } + authorization := strings.Fields(r.Header.Get("Authorization")) + if len(authorization) != 2 || !strings.EqualFold(authorization[0], "Bearer") { + fail(http.StatusUnauthorized) + return + } + var input struct { + EnvironmentID string `json:"environment_id"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + if len(r.URL.Query()) != 0 || decoder.Decode(&input) != nil || input.EnvironmentID == "" || decoder.Decode(new(any)) != io.EOF { + fail(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + binding, err := s.EnrollRuntime(ctx, input.EnvironmentID, device.HashCredential(authorization[1])) + switch { + case errors.Is(err, store.ErrNotFound): + fail(http.StatusUnauthorized) + case errors.Is(err, store.ErrDeviceBindingConflict): + fail(http.StatusConflict) + case err != nil: + fail(http.StatusServiceUnavailable) + default: + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(struct { + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + EnvironmentID string `json:"environment_id"` + WorkspaceDirectory string `json:"workspace_directory"` + }{binding.DeviceID, binding.SessionID, binding.EnvironmentID, binding.WorkspaceDirectory}) + } + }) +} diff --git a/services/agents-api/internal/runtimeenrollment/enrollment_test.go b/services/agents-api/internal/runtimeenrollment/enrollment_test.go new file mode 100644 index 000000000..96f1417f3 --- /dev/null +++ b/services/agents-api/internal/runtimeenrollment/enrollment_test.go @@ -0,0 +1,58 @@ +package runtimeenrollment + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type enrollmentStub struct { + calls int + err error +} + +func (s *enrollmentStub) EnrollRuntime(_ context.Context, environment, digest string) (store.RuntimeEnrollment, error) { + s.calls++ + if environment != "environment" || digest != device.HashCredential("private-test-token") { + return store.RuntimeEnrollment{}, errors.New("unexpected enrollment input") + } + return store.RuntimeEnrollment{DeviceID: "device", SessionID: "session", EnvironmentID: environment, WorkspaceDirectory: "/workspace"}, s.err +} + +func TestEnrollmentConnectionContract(t *testing.T) { + for _, test := range []struct { + name, body, authorization string + err error + status, calls int + }{ + {"valid", `{"environment_id":"environment"}`, "Bearer private-test-token", nil, 200, 1}, + {"missing authority", `{"environment_id":"environment"}`, "", nil, 401, 0}, + {"caller binding", `{"environment_id":"environment","session_id":"other"}`, "Bearer private-test-token", nil, 400, 0}, + {"extra input", `{"environment_id":"environment"}{}`, "Bearer private-test-token", nil, 400, 0}, + {"foreign", `{"environment_id":"environment"}`, "Bearer private-test-token", store.ErrNotFound, 401, 1}, + {"conflict", `{"environment_id":"environment"}`, "Bearer private-test-token", store.ErrDeviceBindingConflict, 409, 1}, + {"internal failure", `{"environment_id":"environment"}`, "Bearer private-test-token", errors.New("private database detail"), 503, 1}, + } { + t.Run(test.name, func(t *testing.T) { + s := &enrollmentStub{err: test.err} + req := httptest.NewRequest("POST", "/api/v1/agent-daemon/enroll", strings.NewReader(test.body)) + req.Header.Set("Authorization", test.authorization) + response := httptest.NewRecorder() + EnrollmentHandler(s).ServeHTTP(response, req) + if response.Code != test.status || s.calls != test.calls { + t.Fatalf("status/calls %d/%d", response.Code, s.calls) + } + if strings.Contains(response.Body.String(), "private") { + t.Fatal("enrollment leaked confidential details") + } + if response.Code == 200 && (response.Body.String() != `{"device_id":"device","session_id":"session","environment_id":"environment","workspace_directory":"/workspace"}`+"\n" || response.Header().Get("Cache-Control") != "no-store") { + t.Fatalf("binding response %s", response.Body.String()) + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/e2b/bootstrap.go b/services/agents-api/internal/sandbox/e2b/bootstrap.go deleted file mode 100644 index a1dd7b94d..000000000 --- a/services/agents-api/internal/sandbox/e2b/bootstrap.go +++ /dev/null @@ -1,117 +0,0 @@ -package e2b - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -const bootstrapInput = "/root/.parsar/e2b/bootstrap.json" -const bootstrapReceipt = "/root/.parsar/e2b/ready.json" - -func basicUser(user string) string { - return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":")) -} - -func (p *Provider) file(ctx context.Context, a allocation, name string, data []byte) ([]byte, error) { - if a.AccessToken == "" { - return nil, sandbox.ErrOwnership - } - method := http.MethodGet - var body bytes.Buffer - var contentType string - if data != nil { - method = http.MethodPost - writer := multipart.NewWriter(&body) - part, e := writer.CreateFormFile("file", "bootstrap.json") - if e != nil { - return nil, e - } - if _, e = part.Write(data); e != nil { - return nil, e - } - if e = writer.Close(); e != nil { - return nil, e - } - contentType = writer.FormDataContentType() - } - req, e := http.NewRequestWithContext(ctx, method, envdURL+"/files?"+url.Values{"path": {name}, "username": {"root"}}.Encode(), &body) - if e != nil { - return nil, sandbox.ErrInvalid - } - req.Header.Set("X-Access-Token", a.AccessToken) - req.Header.Set("E2b-Sandbox-Id", a.ID) - req.Header.Set("E2b-Sandbox-Port", "49983") - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - response, e := p.client.Do(req) - if e != nil { - return nil, errors.Join(errors.New("E2B initialization file request failed"), ctx.Err()) - } - defer response.Body.Close() - if response.StatusCode == 404 { - return nil, sandbox.ErrNotFound - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, fmt.Errorf("E2B initialization file request returned HTTP %d", response.StatusCode) - } - output, e := io.ReadAll(io.LimitReader(response.Body, 65537)) - if e != nil || len(output) > 65536 { - return nil, errors.New("invalid E2B initialization file response") - } - return output, nil -} -func (p *Provider) bootstrap(ctx context.Context, a allocation, b sandbox.Bootstrap) error { - raw, e := json.Marshal(struct { - sandbox.Reference - SessionID string `json:"session_id"` - DeviceID string `json:"runtime_id"` - CoreURL string `json:"server_url"` - Credential string `json:"runner_credential"` - NetworkAccess string `json:"network_access"` - }{b.Reference, b.SessionID, b.DeviceID, b.CoreURL, b.Credential, b.NetworkAccess}) - if e != nil { - return e - } - // E2B reinitializes /run when booting a template. /root remains root-private - // on persistent disk, including while envd creates the input's parent directory. - if _, e = p.file(ctx, a, bootstrapInput, raw); e != nil { - return e - } - result, e := p.run(ctx, a, "root", sandbox.Command{Args: []string{"/usr/bin/python3", "/opt/parsar-e2b/init.py"}, Directory: "/root"}) - if e != nil { - return e - } - if result.ExitCode != 0 { - return errors.New("E2B Runtime initialization failed") - } - return nil -} -func (p *Provider) completed(ctx context.Context, a allocation, r sandbox.Reference) (bool, error) { - raw, e := p.file(ctx, a, bootstrapReceipt, nil) - if errors.Is(e, sandbox.ErrNotFound) { - return false, nil - } - if e != nil { - return false, e - } - var receipt struct { - sandbox.Reference - SessionID string `json:"session_id"` - DeviceID string `json:"runtime_id"` - } - if json.Unmarshal(raw, &receipt) != nil || receipt.Reference != r || !validID(receipt.SessionID) || !validID(receipt.DeviceID) || receipt.SessionID != a.Metadata[metadataPrefix+"session"] || receipt.DeviceID != a.Metadata[metadataPrefix+"device"] { - return false, sandbox.ErrOwnership - } - return true, nil -} diff --git a/services/agents-api/internal/sandbox/e2b/command.go b/services/agents-api/internal/sandbox/e2b/command.go deleted file mode 100644 index 508db8194..000000000 --- a/services/agents-api/internal/sandbox/e2b/command.go +++ /dev/null @@ -1,103 +0,0 @@ -package e2b - -import ( - "bytes" - "context" - "errors" - "path" - - "connectrpc.com/connect" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" - "google.golang.org/protobuf/proto" -) - -func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - if len(c.Args) == 0 || c.Args[0] == "" || len(c.Stdin) > sandbox.MaxCommandInputBytes || (c.Directory != "" && !path.IsAbs(c.Directory)) { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - a, e := p.inspect(ctx, r) - if e != nil { - return sandbox.CommandResult{}, e - } - return p.run(ctx, a, "runtime", c) -} - -// Only trusted initialization calls this transport. Native execution and daily -// Files stay on the authenticated Core/Runtime connection. -func (p *Provider) run(ctx context.Context, a allocation, user string, c sandbox.Command) (sandbox.CommandResult, error) { - var result sandbox.CommandResult - if _, ok := ctx.Deadline(); !ok || a.AccessToken == "" { - return result, sandbox.ErrInvalid - } - ctx, cancel := context.WithCancel(ctx) - defer cancel() - request := connect.NewRequest(&process.StartRequest{Process: &process.ProcessConfig{Cmd: c.Args[0], Args: c.Args[1:], Cwd: nil}, Stdin: proto.Bool(c.Stdin != nil)}) - if c.Directory != "" { - request.Msg.Process.Cwd = proto.String(c.Directory) - } - request.Header().Set("X-Access-Token", a.AccessToken) - request.Header().Set("E2b-Sandbox-Id", a.ID) - request.Header().Set("E2b-Sandbox-Port", "49983") - request.Header().Set("Authorization", basicUser(user)) - client := connect.NewClient[process.StartRequest, process.StartResponse](p.client, envdURL+"/process.Process/Start", connect.WithReadMaxBytes(1024*1024)) - stream, e := client.CallServerStream(ctx, request) - if e != nil { - return result, errors.Join(sandbox.ErrCommandUnconfirmed, ctx.Err()) - } - defer stream.Close() - var stdout, stderr bytes.Buffer - ended := false - var written chan error - defer func() { - cancel() - if written != nil { - <-written - } - }() - for stream.Receive() { - event := stream.Msg().GetEvent() - if start := event.GetStart(); start != nil && c.Stdin != nil { - if written != nil || start.GetPid() == 0 { - return result, sandbox.ErrCommandUnconfirmed - } - written = make(chan error, 1) - go func() { - err := p.sendInput(ctx, request.Header(), start.GetPid(), c.Stdin) - written <- err - if err != nil { - cancel() - } - }() - } - if data := event.GetData(); data != nil { - if stdout.Len()+stderr.Len()+len(data.GetStdout())+len(data.GetStderr()) > 1024*1024 { - return result, sandbox.ErrCommandUnconfirmed - } - stdout.Write(data.GetStdout()) - stderr.Write(data.GetStderr()) - } - if end := event.GetEnd(); end != nil { - if ended || !end.GetExited() { - return result, sandbox.ErrCommandUnconfirmed - } - result.ExitCode = int(end.GetExitCode()) - ended = true - } - } - if stream.Err() != nil || !ended { - return sandbox.CommandResult{}, errors.Join(sandbox.ErrCommandUnconfirmed, ctx.Err()) - } - if c.Stdin != nil { - if written == nil { - return result, sandbox.ErrCommandUnconfirmed - } - err := <-written - written = nil - if err != nil { - return result, sandbox.ErrCommandUnconfirmed - } - } - result.Stdout, result.Stderr = stdout.String(), stderr.String() - return result, nil -} diff --git a/services/agents-api/internal/sandbox/e2b/command_input.go b/services/agents-api/internal/sandbox/e2b/command_input.go deleted file mode 100644 index 7f1602f43..000000000 --- a/services/agents-api/internal/sandbox/e2b/command_input.go +++ /dev/null @@ -1,39 +0,0 @@ -package e2b - -import ( - "context" - "net/http" - - "connectrpc.com/connect" - process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" -) - -func (p *Provider) sendInput(ctx context.Context, headers http.Header, pid uint32, input []byte) error { - selector := &process.ProcessSelector{Selector: &process.ProcessSelector_Pid{Pid: pid}} - sender := connect.NewClient[process.SendInputRequest, process.SendInputResponse](p.client, envdURL+"/process.Process/SendInput", connect.WithReadMaxBytes(65536)) - for len(input) > 0 { - count := min(len(input), 1024*1024) - request := connect.NewRequest(&process.SendInputRequest{Process: selector, Input: &process.ProcessInput{Input: &process.ProcessInput_Stdin{Stdin: input[:count]}}}) - copyCommandHeaders(request.Header(), headers) - if _, err := sender.CallUnary(ctx, request); err != nil { - return err - } - input = input[count:] - } - closer := connect.NewClient[process.CloseStdinRequest, process.CloseStdinResponse](p.client, envdURL+"/process.Process/CloseStdin", connect.WithReadMaxBytes(65536)) - request := connect.NewRequest(&process.CloseStdinRequest{Process: selector}) - copyCommandHeaders(request.Header(), headers) - _, err := closer.CallUnary(ctx, request) - // The process can exit after consuming all bytes, before this EOF request. - // RunCommand still requires a complete successful exit stream. - if connect.CodeOf(err) == connect.CodeNotFound { - return nil - } - return err -} - -func copyCommandHeaders(destination, source http.Header) { - for _, name := range []string{"X-Access-Token", "E2b-Sandbox-Id", "E2b-Sandbox-Port", "Authorization"} { - destination.Set(name, source.Get(name)) - } -} diff --git a/services/agents-api/internal/sandbox/e2b/command_input_test.go b/services/agents-api/internal/sandbox/e2b/command_input_test.go deleted file mode 100644 index fc767c6d8..000000000 --- a/services/agents-api/internal/sandbox/e2b/command_input_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package e2b - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "sync" - "testing" - "time" - - "connectrpc.com/connect" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - process "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess" -) - -type inputFixtureTransport struct{ target *url.URL } - -func (t inputFixtureTransport) RoundTrip(r *http.Request) (*http.Response, error) { - copy := r.Clone(r.Context()) - copy.URL.Scheme = t.target.Scheme - copy.URL.Host = t.target.Host - return http.DefaultTransport.RoundTrip(copy) -} - -func TestStdinExitRequiresCompleteDeliveryAndTerminalStream(t *testing.T) { - for _, tc := range []struct { - name string - sendFailure, closeFailure connect.Code - terminal, want bool - }{ - {name: "EOF", terminal: true, want: true}, - {name: "exit before EOF", closeFailure: connect.CodeNotFound, terminal: true, want: true}, - {name: "missing exit", closeFailure: connect.CodeNotFound}, - {name: "input not delivered", sendFailure: connect.CodeNotFound, terminal: true}, - {name: "unconfirmed EOF", closeFailure: connect.CodeUnavailable, terminal: true}, - } { - t.Run(tc.name, func(t *testing.T) { - finished := make(chan struct{}) - var once sync.Once - finish := func() { once.Do(func() { close(finished) }) } - mux := http.NewServeMux() - mux.Handle("/process.Process/Start", connect.NewServerStreamHandler("/process.Process/Start", func(ctx context.Context, _ *connect.Request[process.StartRequest], s *connect.ServerStream[process.StartResponse]) error { - if err := s.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_Start{Start: &process.ProcessEvent_StartEvent{Pid: 123}}}}); err != nil { - return err - } - select { - case <-finished: - case <-ctx.Done(): - return ctx.Err() - } - if !tc.terminal { - return nil - } - return s.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_End{End: &process.ProcessEvent_EndEvent{Exited: true}}}}) - })) - mux.Handle("/process.Process/SendInput", connect.NewUnaryHandler("/process.Process/SendInput", func(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { - if tc.sendFailure != 0 { - finish() - return nil, connect.NewError(tc.sendFailure, errors.New("fixture input failure")) - } - return connect.NewResponse(&process.SendInputResponse{}), nil - })) - mux.Handle("/process.Process/CloseStdin", connect.NewUnaryHandler("/process.Process/CloseStdin", func(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { - finish() - if tc.closeFailure != 0 { - return nil, connect.NewError(tc.closeFailure, errors.New("fixture EOF failure")) - } - return connect.NewResponse(&process.CloseStdinResponse{}), nil - })) - server := httptest.NewServer(mux) - defer server.Close() - target, _ := url.Parse(server.URL) - provider := Provider{client: &http.Client{Transport: inputFixtureTransport{target: target}}} - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - _, err := provider.run(ctx, allocation{ID: "fixture", AccessToken: "fixture"}, "runtime", sandbox.Command{Args: []string{"fixture"}, Stdin: []byte("file")}) - if (err == nil) != tc.want { - t.Fatal("unexpected stdin completion", err) - } - }) - } -} diff --git a/services/agents-api/internal/sandbox/e2b/control.go b/services/agents-api/internal/sandbox/e2b/control.go deleted file mode 100644 index e419f0c7e..000000000 --- a/services/agents-api/internal/sandbox/e2b/control.go +++ /dev/null @@ -1,130 +0,0 @@ -package e2b - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" -) - -const apiURL = "https://api.e2b.app" -const envdURL = "https://sandbox.e2b.app" -const metadataPrefix = "io.parsar.agents-api." - -type allocation struct { - ID string `json:"sandboxID"` - State string `json:"state"` - AccessToken string `json:"envdAccessToken"` - Metadata map[string]string `json:"metadata"` -} - -func (p *Provider) metadata(r sandbox.Reference) map[string]string { - return map[string]string{metadataPrefix + "installation": p.config.InstallationID, metadataPrefix + "tenant": r.TenantID, metadataPrefix + "environment": r.EnvironmentID, metadataPrefix + "allocation": r.AllocationID} -} -func (p *Provider) owns(a allocation, r sandbox.Reference) bool { - for k, v := range p.metadata(r) { - if a.Metadata[k] != v { - return false - } - } - return a.ID != "" -} - -// The caller owns retries. In particular, never replay a lost Create response. -// Provider responses and transport errors can contain secrets; return safe status only. -func (p *Provider) request(ctx context.Context, method, path string, body, out any) (http.Header, error) { - var input io.Reader - if body != nil { - raw, e := json.Marshal(body) - if e != nil { - return nil, sandbox.ErrInvalid - } - input = bytes.NewReader(raw) - } - req, e := http.NewRequestWithContext(ctx, method, apiURL+path, input) - if e != nil { - return nil, sandbox.ErrInvalid - } - req.Header.Set("X-API-Key", p.config.APIKey) - req.Header.Set("Content-Type", "application/json") - response, e := p.client.Do(req) - if e != nil { - return nil, errors.Join(errors.New("E2B control request failed"), ctx.Err()) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNotFound { - return response.Header, sandbox.ErrNotFound - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return response.Header, fmt.Errorf("E2B control request returned HTTP %d", response.StatusCode) - } - if out != nil { - raw, e := io.ReadAll(io.LimitReader(response.Body, 1024*1024+1)) - if e != nil || len(raw) > 1024*1024 || json.Unmarshal(raw, out) != nil { - return nil, errors.New("invalid E2B control response") - } - } - return response.Header, nil -} - -func (p *Provider) allocations(ctx context.Context, r sandbox.Reference) ([]allocation, error) { - if !validReference(r) { - return nil, sandbox.ErrInvalid - } - metadata := url.Values{} - for k, v := range p.metadata(r) { - metadata.Set(k, v) - } - query := url.Values{"metadata": {metadata.Encode()}, "limit": {"100"}, "state": {"running,paused"}} - var result []allocation - seen := map[string]bool{} - for { - var page []allocation - headers, e := p.request(ctx, http.MethodGet, "/v2/sandboxes?"+query.Encode(), nil, &page) - if e != nil { - return nil, e - } - for _, a := range page { - if !p.owns(a, r) { - return nil, sandbox.ErrOwnership - } - result = append(result, a) - } - token := headers.Get("X-Next-Token") - if token == "" { - return result, nil - } - if seen[token] { - return nil, errors.New("invalid E2B pagination") - } - seen[token] = true - query.Set("nextToken", token) - } -} -func (p *Provider) inspectID(ctx context.Context, id string, r sandbox.Reference) (allocation, error) { - var a allocation - _, e := p.request(ctx, http.MethodGet, "/sandboxes/"+url.PathEscape(id), nil, &a) - if e == nil && (a.ID != id || !p.owns(a, r)) { - e = sandbox.ErrOwnership - } - return a, e -} -func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (allocation, error) { - all, e := p.allocations(ctx, r) - if e != nil { - return allocation{}, e - } - if len(all) == 0 { - return allocation{}, sandbox.ErrNotFound - } - if len(all) != 1 { - return allocation{}, sandbox.ErrOwnership - } - return p.inspectID(ctx, all[0].ID, r) -} diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE b/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE deleted file mode 100644 index ec47fef19..000000000 --- a/services/agents-api/internal/sandbox/e2b/envdprocess/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2023 FoundryLabs, Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/README.md b/services/agents-api/internal/sandbox/e2b/envdprocess/README.md deleted file mode 100644 index e9c371346..000000000 --- a/services/agents-api/internal/sandbox/e2b/envdprocess/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# E2B envd process protocol - -`process.proto` is copied without changes from E2B runtime commit -`fad70f393e800cee0278669a63976c3aaa00871b`, -`packages/envd/spec/process/process.proto` (Apache-2.0, accompanying LICENSE). -Its SHA-256 is `8edd9358c7dbfcad96796b3f0ed8d14c262b8b14d6bc7d5e84d468941511b8e0`. -The generated file uses protoc 32.1 and protoc-gen-go v1.36.12, matching the -repository's protobuf runtime. Regenerate from this directory: - -```sh -protoc --go_out=. --go_opt=paths=source_relative \ - --go_opt=Mprocess.proto=github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b/envdprocess \ - process.proto -``` - -Only the maintained Connect client is required at runtime. Do not import the E2B -server monorepo or hand-write Connect framing. The full official message schema is -retained; it does not expose these process operations through public Agents API. diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go b/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go deleted file mode 100644 index db3bd24c8..000000000 --- a/services/agents-api/internal/sandbox/e2b/envdprocess/process.pb.go +++ /dev/null @@ -1,1969 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.12 -// protoc v6.32.1 -// source: process.proto - -package envdprocess - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Signal int32 - -const ( - Signal_SIGNAL_UNSPECIFIED Signal = 0 - Signal_SIGNAL_SIGTERM Signal = 15 - Signal_SIGNAL_SIGKILL Signal = 9 -) - -// Enum value maps for Signal. -var ( - Signal_name = map[int32]string{ - 0: "SIGNAL_UNSPECIFIED", - 15: "SIGNAL_SIGTERM", - 9: "SIGNAL_SIGKILL", - } - Signal_value = map[string]int32{ - "SIGNAL_UNSPECIFIED": 0, - "SIGNAL_SIGTERM": 15, - "SIGNAL_SIGKILL": 9, - } -) - -func (x Signal) Enum() *Signal { - p := new(Signal) - *p = x - return p -} - -func (x Signal) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (Signal) Descriptor() protoreflect.EnumDescriptor { - return file_process_proto_enumTypes[0].Descriptor() -} - -func (Signal) Type() protoreflect.EnumType { - return &file_process_proto_enumTypes[0] -} - -func (x Signal) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use Signal.Descriptor instead. -func (Signal) EnumDescriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{0} -} - -type PTY struct { - state protoimpl.MessageState `protogen:"open.v1"` - Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PTY) Reset() { - *x = PTY{} - mi := &file_process_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PTY) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PTY) ProtoMessage() {} - -func (x *PTY) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PTY.ProtoReflect.Descriptor instead. -func (*PTY) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{0} -} - -func (x *PTY) GetSize() *PTY_Size { - if x != nil { - return x.Size - } - return nil -} - -type ProcessConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` - Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` - Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessConfig) Reset() { - *x = ProcessConfig{} - mi := &file_process_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessConfig) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessConfig) ProtoMessage() {} - -func (x *ProcessConfig) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. -func (*ProcessConfig) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{1} -} - -func (x *ProcessConfig) GetCmd() string { - if x != nil { - return x.Cmd - } - return "" -} - -func (x *ProcessConfig) GetArgs() []string { - if x != nil { - return x.Args - } - return nil -} - -func (x *ProcessConfig) GetEnvs() map[string]string { - if x != nil { - return x.Envs - } - return nil -} - -func (x *ProcessConfig) GetCwd() string { - if x != nil && x.Cwd != nil { - return *x.Cwd - } - return "" -} - -type ListRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListRequest) Reset() { - *x = ListRequest{} - mi := &file_process_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListRequest) ProtoMessage() {} - -func (x *ListRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. -func (*ListRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{2} -} - -type ProcessInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` - Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessInfo) Reset() { - *x = ProcessInfo{} - mi := &file_process_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessInfo) ProtoMessage() {} - -func (x *ProcessInfo) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. -func (*ProcessInfo) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{3} -} - -func (x *ProcessInfo) GetConfig() *ProcessConfig { - if x != nil { - return x.Config - } - return nil -} - -func (x *ProcessInfo) GetPid() uint32 { - if x != nil { - return x.Pid - } - return 0 -} - -func (x *ProcessInfo) GetTag() string { - if x != nil && x.Tag != nil { - return *x.Tag - } - return "" -} - -type ListResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListResponse) Reset() { - *x = ListResponse{} - mi := &file_process_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListResponse) ProtoMessage() {} - -func (x *ListResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. -func (*ListResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{4} -} - -func (x *ListResponse) GetProcesses() []*ProcessInfo { - if x != nil { - return x.Processes - } - return nil -} - -type StartRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` - Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` - // This is optional for backwards compatibility. - // We default to true. New SDK versions will set this to false by default. - Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartRequest) Reset() { - *x = StartRequest{} - mi := &file_process_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartRequest) ProtoMessage() {} - -func (x *StartRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. -func (*StartRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{5} -} - -func (x *StartRequest) GetProcess() *ProcessConfig { - if x != nil { - return x.Process - } - return nil -} - -func (x *StartRequest) GetPty() *PTY { - if x != nil { - return x.Pty - } - return nil -} - -func (x *StartRequest) GetTag() string { - if x != nil && x.Tag != nil { - return *x.Tag - } - return "" -} - -func (x *StartRequest) GetStdin() bool { - if x != nil && x.Stdin != nil { - return *x.Stdin - } - return false -} - -type UpdateRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateRequest) Reset() { - *x = UpdateRequest{} - mi := &file_process_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateRequest) ProtoMessage() {} - -func (x *UpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. -func (*UpdateRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{6} -} - -func (x *UpdateRequest) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -func (x *UpdateRequest) GetPty() *PTY { - if x != nil { - return x.Pty - } - return nil -} - -type UpdateResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateResponse) Reset() { - *x = UpdateResponse{} - mi := &file_process_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateResponse) ProtoMessage() {} - -func (x *UpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. -func (*UpdateResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{7} -} - -type ProcessEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Event: - // - // *ProcessEvent_Start - // *ProcessEvent_Data - // *ProcessEvent_End - // *ProcessEvent_Keepalive - Event isProcessEvent_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessEvent) Reset() { - *x = ProcessEvent{} - mi := &file_process_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessEvent) ProtoMessage() {} - -func (x *ProcessEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. -func (*ProcessEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{8} -} - -func (x *ProcessEvent) GetEvent() isProcessEvent_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { - if x != nil { - if x, ok := x.Event.(*ProcessEvent_Start); ok { - return x.Start - } - } - return nil -} - -func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { - if x != nil { - if x, ok := x.Event.(*ProcessEvent_Data); ok { - return x.Data - } - } - return nil -} - -func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { - if x != nil { - if x, ok := x.Event.(*ProcessEvent_End); ok { - return x.End - } - } - return nil -} - -func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { - if x != nil { - if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { - return x.Keepalive - } - } - return nil -} - -type isProcessEvent_Event interface { - isProcessEvent_Event() -} - -type ProcessEvent_Start struct { - Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` -} - -type ProcessEvent_Data struct { - Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` -} - -type ProcessEvent_End struct { - End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` -} - -type ProcessEvent_Keepalive struct { - Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` -} - -func (*ProcessEvent_Start) isProcessEvent_Event() {} - -func (*ProcessEvent_Data) isProcessEvent_Event() {} - -func (*ProcessEvent_End) isProcessEvent_Event() {} - -func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} - -type StartResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartResponse) Reset() { - *x = StartResponse{} - mi := &file_process_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartResponse) ProtoMessage() {} - -func (x *StartResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. -func (*StartResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{9} -} - -func (x *StartResponse) GetEvent() *ProcessEvent { - if x != nil { - return x.Event - } - return nil -} - -type ConnectResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConnectResponse) Reset() { - *x = ConnectResponse{} - mi := &file_process_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConnectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConnectResponse) ProtoMessage() {} - -func (x *ConnectResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. -func (*ConnectResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{10} -} - -func (x *ConnectResponse) GetEvent() *ProcessEvent { - if x != nil { - return x.Event - } - return nil -} - -type SendInputRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SendInputRequest) Reset() { - *x = SendInputRequest{} - mi := &file_process_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SendInputRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SendInputRequest) ProtoMessage() {} - -func (x *SendInputRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. -func (*SendInputRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{11} -} - -func (x *SendInputRequest) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -func (x *SendInputRequest) GetInput() *ProcessInput { - if x != nil { - return x.Input - } - return nil -} - -type SendInputResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SendInputResponse) Reset() { - *x = SendInputResponse{} - mi := &file_process_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SendInputResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SendInputResponse) ProtoMessage() {} - -func (x *SendInputResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. -func (*SendInputResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{12} -} - -type ProcessInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Input: - // - // *ProcessInput_Stdin - // *ProcessInput_Pty - Input isProcessInput_Input `protobuf_oneof:"input"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessInput) Reset() { - *x = ProcessInput{} - mi := &file_process_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessInput) ProtoMessage() {} - -func (x *ProcessInput) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. -func (*ProcessInput) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{13} -} - -func (x *ProcessInput) GetInput() isProcessInput_Input { - if x != nil { - return x.Input - } - return nil -} - -func (x *ProcessInput) GetStdin() []byte { - if x != nil { - if x, ok := x.Input.(*ProcessInput_Stdin); ok { - return x.Stdin - } - } - return nil -} - -func (x *ProcessInput) GetPty() []byte { - if x != nil { - if x, ok := x.Input.(*ProcessInput_Pty); ok { - return x.Pty - } - } - return nil -} - -type isProcessInput_Input interface { - isProcessInput_Input() -} - -type ProcessInput_Stdin struct { - Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` -} - -type ProcessInput_Pty struct { - Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` -} - -func (*ProcessInput_Stdin) isProcessInput_Input() {} - -func (*ProcessInput_Pty) isProcessInput_Input() {} - -type StreamInputRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Event: - // - // *StreamInputRequest_Start - // *StreamInputRequest_Data - // *StreamInputRequest_Keepalive - Event isStreamInputRequest_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamInputRequest) Reset() { - *x = StreamInputRequest{} - mi := &file_process_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamInputRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamInputRequest) ProtoMessage() {} - -func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. -func (*StreamInputRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{14} -} - -func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { - if x != nil { - if x, ok := x.Event.(*StreamInputRequest_Start); ok { - return x.Start - } - } - return nil -} - -func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { - if x != nil { - if x, ok := x.Event.(*StreamInputRequest_Data); ok { - return x.Data - } - } - return nil -} - -func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { - if x != nil { - if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { - return x.Keepalive - } - } - return nil -} - -type isStreamInputRequest_Event interface { - isStreamInputRequest_Event() -} - -type StreamInputRequest_Start struct { - Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` -} - -type StreamInputRequest_Data struct { - Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` -} - -type StreamInputRequest_Keepalive struct { - Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` -} - -func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} - -func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} - -func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} - -type StreamInputResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamInputResponse) Reset() { - *x = StreamInputResponse{} - mi := &file_process_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamInputResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamInputResponse) ProtoMessage() {} - -func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. -func (*StreamInputResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{15} -} - -type SendSignalRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SendSignalRequest) Reset() { - *x = SendSignalRequest{} - mi := &file_process_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SendSignalRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SendSignalRequest) ProtoMessage() {} - -func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. -func (*SendSignalRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{16} -} - -func (x *SendSignalRequest) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -func (x *SendSignalRequest) GetSignal() Signal { - if x != nil { - return x.Signal - } - return Signal_SIGNAL_UNSPECIFIED -} - -type SendSignalResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SendSignalResponse) Reset() { - *x = SendSignalResponse{} - mi := &file_process_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SendSignalResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SendSignalResponse) ProtoMessage() {} - -func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. -func (*SendSignalResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{17} -} - -type CloseStdinRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CloseStdinRequest) Reset() { - *x = CloseStdinRequest{} - mi := &file_process_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CloseStdinRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CloseStdinRequest) ProtoMessage() {} - -func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. -func (*CloseStdinRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{18} -} - -func (x *CloseStdinRequest) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -type CloseStdinResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CloseStdinResponse) Reset() { - *x = CloseStdinResponse{} - mi := &file_process_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CloseStdinResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CloseStdinResponse) ProtoMessage() {} - -func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. -func (*CloseStdinResponse) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{19} -} - -type ConnectRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ConnectRequest) Reset() { - *x = ConnectRequest{} - mi := &file_process_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ConnectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ConnectRequest) ProtoMessage() {} - -func (x *ConnectRequest) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. -func (*ConnectRequest) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{20} -} - -func (x *ConnectRequest) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -type ProcessSelector struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Selector: - // - // *ProcessSelector_Pid - // *ProcessSelector_Tag - Selector isProcessSelector_Selector `protobuf_oneof:"selector"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessSelector) Reset() { - *x = ProcessSelector{} - mi := &file_process_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessSelector) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessSelector) ProtoMessage() {} - -func (x *ProcessSelector) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. -func (*ProcessSelector) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{21} -} - -func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { - if x != nil { - return x.Selector - } - return nil -} - -func (x *ProcessSelector) GetPid() uint32 { - if x != nil { - if x, ok := x.Selector.(*ProcessSelector_Pid); ok { - return x.Pid - } - } - return 0 -} - -func (x *ProcessSelector) GetTag() string { - if x != nil { - if x, ok := x.Selector.(*ProcessSelector_Tag); ok { - return x.Tag - } - } - return "" -} - -type isProcessSelector_Selector interface { - isProcessSelector_Selector() -} - -type ProcessSelector_Pid struct { - Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` -} - -type ProcessSelector_Tag struct { - Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` -} - -func (*ProcessSelector_Pid) isProcessSelector_Selector() {} - -func (*ProcessSelector_Tag) isProcessSelector_Selector() {} - -type PTY_Size struct { - state protoimpl.MessageState `protogen:"open.v1"` - Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` - Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PTY_Size) Reset() { - *x = PTY_Size{} - mi := &file_process_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PTY_Size) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PTY_Size) ProtoMessage() {} - -func (x *PTY_Size) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[22] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. -func (*PTY_Size) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{0, 0} -} - -func (x *PTY_Size) GetCols() uint32 { - if x != nil { - return x.Cols - } - return 0 -} - -func (x *PTY_Size) GetRows() uint32 { - if x != nil { - return x.Rows - } - return 0 -} - -type ProcessEvent_StartEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessEvent_StartEvent) Reset() { - *x = ProcessEvent_StartEvent{} - mi := &file_process_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessEvent_StartEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessEvent_StartEvent) ProtoMessage() {} - -func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[24] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. -func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{8, 0} -} - -func (x *ProcessEvent_StartEvent) GetPid() uint32 { - if x != nil { - return x.Pid - } - return 0 -} - -type ProcessEvent_DataEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Output: - // - // *ProcessEvent_DataEvent_Stdout - // *ProcessEvent_DataEvent_Stderr - // *ProcessEvent_DataEvent_Pty - Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessEvent_DataEvent) Reset() { - *x = ProcessEvent_DataEvent{} - mi := &file_process_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessEvent_DataEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessEvent_DataEvent) ProtoMessage() {} - -func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[25] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. -func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{8, 1} -} - -func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { - if x != nil { - return x.Output - } - return nil -} - -func (x *ProcessEvent_DataEvent) GetStdout() []byte { - if x != nil { - if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { - return x.Stdout - } - } - return nil -} - -func (x *ProcessEvent_DataEvent) GetStderr() []byte { - if x != nil { - if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { - return x.Stderr - } - } - return nil -} - -func (x *ProcessEvent_DataEvent) GetPty() []byte { - if x != nil { - if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { - return x.Pty - } - } - return nil -} - -type isProcessEvent_DataEvent_Output interface { - isProcessEvent_DataEvent_Output() -} - -type ProcessEvent_DataEvent_Stdout struct { - Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` -} - -type ProcessEvent_DataEvent_Stderr struct { - Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` -} - -type ProcessEvent_DataEvent_Pty struct { - Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` -} - -func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} - -func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} - -func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} - -type ProcessEvent_EndEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` - Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessEvent_EndEvent) Reset() { - *x = ProcessEvent_EndEvent{} - mi := &file_process_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessEvent_EndEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessEvent_EndEvent) ProtoMessage() {} - -func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[26] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. -func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{8, 2} -} - -func (x *ProcessEvent_EndEvent) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *ProcessEvent_EndEvent) GetExited() bool { - if x != nil { - return x.Exited - } - return false -} - -func (x *ProcessEvent_EndEvent) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *ProcessEvent_EndEvent) GetError() string { - if x != nil && x.Error != nil { - return *x.Error - } - return "" -} - -type ProcessEvent_KeepAlive struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ProcessEvent_KeepAlive) Reset() { - *x = ProcessEvent_KeepAlive{} - mi := &file_process_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ProcessEvent_KeepAlive) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ProcessEvent_KeepAlive) ProtoMessage() {} - -func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[27] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. -func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{8, 3} -} - -type StreamInputRequest_StartEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamInputRequest_StartEvent) Reset() { - *x = StreamInputRequest_StartEvent{} - mi := &file_process_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamInputRequest_StartEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamInputRequest_StartEvent) ProtoMessage() {} - -func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[28] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. -func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{14, 0} -} - -func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { - if x != nil { - return x.Process - } - return nil -} - -type StreamInputRequest_DataEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamInputRequest_DataEvent) Reset() { - *x = StreamInputRequest_DataEvent{} - mi := &file_process_proto_msgTypes[29] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamInputRequest_DataEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamInputRequest_DataEvent) ProtoMessage() {} - -func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[29] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. -func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{14, 1} -} - -func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { - if x != nil { - return x.Input - } - return nil -} - -type StreamInputRequest_KeepAlive struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamInputRequest_KeepAlive) Reset() { - *x = StreamInputRequest_KeepAlive{} - mi := &file_process_proto_msgTypes[30] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamInputRequest_KeepAlive) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamInputRequest_KeepAlive) ProtoMessage() {} - -func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { - mi := &file_process_proto_msgTypes[30] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. -func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { - return file_process_proto_rawDescGZIP(), []int{14, 2} -} - -var File_process_proto protoreflect.FileDescriptor - -const file_process_proto_rawDesc = "" + - "\n" + - "\rprocess.proto\x12\aprocess\"\\\n" + - "\x03PTY\x12%\n" + - "\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" + - "\x04Size\x12\x12\n" + - "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + - "\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" + - "\rProcessConfig\x12\x10\n" + - "\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" + - "\x04args\x18\x02 \x03(\tR\x04args\x124\n" + - "\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" + - "\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" + - "\tEnvsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + - "\x04_cwd\"\r\n" + - "\vListRequest\"n\n" + - "\vProcessInfo\x12.\n" + - "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + - "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + - "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + - "\x04_tag\"B\n" + - "\fListResponse\x122\n" + - "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + - "\fStartRequest\x120\n" + - "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + - "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + - "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + - "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + - "\x04_ptyB\x06\n" + - "\x04_tagB\b\n" + - "\x06_stdin\"p\n" + - "\rUpdateRequest\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + - "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + - "\x04_pty\"\x10\n" + - "\x0eUpdateResponse\"\x87\x04\n" + - "\fProcessEvent\x128\n" + - "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + - "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + - "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + - "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + - "\n" + - "StartEvent\x12\x10\n" + - "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + - "\tDataEvent\x12\x18\n" + - "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + - "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + - "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + - "\x06output\x1a|\n" + - "\bEndEvent\x12\x1b\n" + - "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + - "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + - "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + - "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + - "\x06_error\x1a\v\n" + - "\tKeepAliveB\a\n" + - "\x05event\"<\n" + - "\rStartResponse\x12+\n" + - "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + - "\x0fConnectResponse\x12+\n" + - "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + - "\x10SendInputRequest\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + - "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + - "\x11SendInputResponse\"C\n" + - "\fProcessInput\x12\x16\n" + - "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + - "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + - "\x05input\"\xea\x02\n" + - "\x12StreamInputRequest\x12>\n" + - "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + - "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + - "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + - "\n" + - "StartEvent\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + - "\tDataEvent\x12+\n" + - "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + - "\tKeepAliveB\a\n" + - "\x05event\"\x15\n" + - "\x13StreamInputResponse\"p\n" + - "\x11SendSignalRequest\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + - "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + - "\x12SendSignalResponse\"G\n" + - "\x11CloseStdinRequest\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + - "\x12CloseStdinResponse\"D\n" + - "\x0eConnectRequest\x122\n" + - "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + - "\x0fProcessSelector\x12\x12\n" + - "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + - "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + - "\n" + - "\bselector*H\n" + - "\x06Signal\x12\x16\n" + - "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + - "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + - "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + - "\aProcess\x123\n" + - "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + - "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + - "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + - "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + - "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + - "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + - "\n" + - "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + - "\n" + - "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseb\x06proto3" - -var ( - file_process_proto_rawDescOnce sync.Once - file_process_proto_rawDescData []byte -) - -func file_process_proto_rawDescGZIP() []byte { - file_process_proto_rawDescOnce.Do(func() { - file_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc))) - }) - return file_process_proto_rawDescData -} - -var file_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) -var file_process_proto_goTypes = []any{ - (Signal)(0), // 0: process.Signal - (*PTY)(nil), // 1: process.PTY - (*ProcessConfig)(nil), // 2: process.ProcessConfig - (*ListRequest)(nil), // 3: process.ListRequest - (*ProcessInfo)(nil), // 4: process.ProcessInfo - (*ListResponse)(nil), // 5: process.ListResponse - (*StartRequest)(nil), // 6: process.StartRequest - (*UpdateRequest)(nil), // 7: process.UpdateRequest - (*UpdateResponse)(nil), // 8: process.UpdateResponse - (*ProcessEvent)(nil), // 9: process.ProcessEvent - (*StartResponse)(nil), // 10: process.StartResponse - (*ConnectResponse)(nil), // 11: process.ConnectResponse - (*SendInputRequest)(nil), // 12: process.SendInputRequest - (*SendInputResponse)(nil), // 13: process.SendInputResponse - (*ProcessInput)(nil), // 14: process.ProcessInput - (*StreamInputRequest)(nil), // 15: process.StreamInputRequest - (*StreamInputResponse)(nil), // 16: process.StreamInputResponse - (*SendSignalRequest)(nil), // 17: process.SendSignalRequest - (*SendSignalResponse)(nil), // 18: process.SendSignalResponse - (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest - (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse - (*ConnectRequest)(nil), // 21: process.ConnectRequest - (*ProcessSelector)(nil), // 22: process.ProcessSelector - (*PTY_Size)(nil), // 23: process.PTY.Size - nil, // 24: process.ProcessConfig.EnvsEntry - (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent - (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent - (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent - (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive - (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent - (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent - (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive -} -var file_process_proto_depIdxs = []int32{ - 23, // 0: process.PTY.size:type_name -> process.PTY.Size - 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry - 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig - 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo - 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig - 1, // 5: process.StartRequest.pty:type_name -> process.PTY - 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector - 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY - 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent - 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent - 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent - 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive - 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent - 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent - 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector - 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput - 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent - 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent - 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive - 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector - 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal - 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector - 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector - 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector - 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput - 3, // 25: process.Process.List:input_type -> process.ListRequest - 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest - 6, // 27: process.Process.Start:input_type -> process.StartRequest - 7, // 28: process.Process.Update:input_type -> process.UpdateRequest - 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest - 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest - 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest - 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest - 5, // 33: process.Process.List:output_type -> process.ListResponse - 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse - 10, // 35: process.Process.Start:output_type -> process.StartResponse - 8, // 36: process.Process.Update:output_type -> process.UpdateResponse - 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse - 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse - 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse - 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse - 33, // [33:41] is the sub-list for method output_type - 25, // [25:33] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name -} - -func init() { file_process_proto_init() } -func file_process_proto_init() { - if File_process_proto != nil { - return - } - file_process_proto_msgTypes[1].OneofWrappers = []any{} - file_process_proto_msgTypes[3].OneofWrappers = []any{} - file_process_proto_msgTypes[5].OneofWrappers = []any{} - file_process_proto_msgTypes[6].OneofWrappers = []any{} - file_process_proto_msgTypes[8].OneofWrappers = []any{ - (*ProcessEvent_Start)(nil), - (*ProcessEvent_Data)(nil), - (*ProcessEvent_End)(nil), - (*ProcessEvent_Keepalive)(nil), - } - file_process_proto_msgTypes[13].OneofWrappers = []any{ - (*ProcessInput_Stdin)(nil), - (*ProcessInput_Pty)(nil), - } - file_process_proto_msgTypes[14].OneofWrappers = []any{ - (*StreamInputRequest_Start)(nil), - (*StreamInputRequest_Data)(nil), - (*StreamInputRequest_Keepalive)(nil), - } - file_process_proto_msgTypes[21].OneofWrappers = []any{ - (*ProcessSelector_Pid)(nil), - (*ProcessSelector_Tag)(nil), - } - file_process_proto_msgTypes[25].OneofWrappers = []any{ - (*ProcessEvent_DataEvent_Stdout)(nil), - (*ProcessEvent_DataEvent_Stderr)(nil), - (*ProcessEvent_DataEvent_Pty)(nil), - } - file_process_proto_msgTypes[26].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc)), - NumEnums: 1, - NumMessages: 31, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_process_proto_goTypes, - DependencyIndexes: file_process_proto_depIdxs, - EnumInfos: file_process_proto_enumTypes, - MessageInfos: file_process_proto_msgTypes, - }.Build() - File_process_proto = out.File - file_process_proto_goTypes = nil - file_process_proto_depIdxs = nil -} diff --git a/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto b/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto deleted file mode 100644 index 99376a0e3..000000000 --- a/services/agents-api/internal/sandbox/e2b/envdprocess/process.proto +++ /dev/null @@ -1,171 +0,0 @@ -syntax = "proto3"; - -package process; - -service Process { - rpc List(ListRequest) returns (ListResponse); - - rpc Connect(ConnectRequest) returns (stream ConnectResponse); - rpc Start(StartRequest) returns (stream StartResponse); - - rpc Update(UpdateRequest) returns (UpdateResponse); - - // Client input stream ensures ordering of messages - rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); - rpc SendInput(SendInputRequest) returns (SendInputResponse); - rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); - - // Close stdin to signal EOF to the process. - // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. - rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); -} - -message PTY { - Size size = 1; - - message Size { - uint32 cols = 1; - uint32 rows = 2; - } -} - -message ProcessConfig { - string cmd = 1; - repeated string args = 2; - - map envs = 3; - optional string cwd = 4; -} - -message ListRequest {} - -message ProcessInfo { - ProcessConfig config = 1; - uint32 pid = 2; - optional string tag = 3; -} - -message ListResponse { - repeated ProcessInfo processes = 1; -} - -message StartRequest { - ProcessConfig process = 1; - optional PTY pty = 2; - optional string tag = 3; - // This is optional for backwards compatibility. - // We default to true. New SDK versions will set this to false by default. - optional bool stdin = 4; -} - -message UpdateRequest { - ProcessSelector process = 1; - - optional PTY pty = 2; -} - -message UpdateResponse {} - -message ProcessEvent { - oneof event { - StartEvent start = 1; - DataEvent data = 2; - EndEvent end = 3; - KeepAlive keepalive = 4; - } - - message StartEvent { - uint32 pid = 1; - } - - message DataEvent { - oneof output { - bytes stdout = 1; - bytes stderr = 2; - bytes pty = 3; - } - } - - message EndEvent { - sint32 exit_code = 1; - bool exited = 2; - string status = 3; - optional string error = 4; - } - - message KeepAlive {} -} - -message StartResponse { - ProcessEvent event = 1; -} - -message ConnectResponse { - ProcessEvent event = 1; -} - -message SendInputRequest { - ProcessSelector process = 1; - - ProcessInput input = 2; -} - -message SendInputResponse {} - -message ProcessInput { - oneof input { - bytes stdin = 1; - bytes pty = 2; - } -} - -message StreamInputRequest { - oneof event { - StartEvent start = 1; - DataEvent data = 2; - KeepAlive keepalive = 3; - } - - message StartEvent { - ProcessSelector process = 1; - } - - message DataEvent { - ProcessInput input = 2; - } - - message KeepAlive {} -} - -message StreamInputResponse {} - -enum Signal { - SIGNAL_UNSPECIFIED = 0; - SIGNAL_SIGTERM = 15; - SIGNAL_SIGKILL = 9; -} - -message SendSignalRequest { - ProcessSelector process = 1; - - Signal signal = 2; -} - -message SendSignalResponse {} - -message CloseStdinRequest { - ProcessSelector process = 1; -} - -message CloseStdinResponse {} - -message ConnectRequest { - ProcessSelector process = 1; -} - -message ProcessSelector { - oneof selector { - uint32 pid = 1; - string tag = 2; - } -} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go deleted file mode 100644 index 8ad05ee18..000000000 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ /dev/null @@ -1,130 +0,0 @@ -// Package e2b implements compute lifecycle for the colocated Runtime. It does not -// implement model execution, public Files, or a second Runtime transport. -package e2b - -import ( - "context" - "errors" - "net/http" - "net/url" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -type Config struct { - InstallationID, APIKey, Template string - // LeaseSeconds must exceed Core's one-hour disconnect grace. Renewal changes - // the expiry of the original running VM; it never resumes or replaces it. - LeaseSeconds int -} -type Provider struct { - config Config - client *http.Client -} - -var _ sandbox.Provider = (*Provider)(nil) - -func validID(v string) bool { - u, e := uuid.Parse(v) - return e == nil && u != uuid.Nil && u.String() == v -} -func validReference(r sandbox.Reference) bool { - return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) -} - -func New(config Config) (*Provider, error) { - parts := strings.Split(config.Template, ":") - if !validID(config.InstallationID) || strings.TrimSpace(config.APIKey) == "" || len(parts) != 2 || !validID(parts[1]) || parts[0] == "" || strings.Trim(parts[0], "abcdefghijklmnopqrstuvwxyz0123456789") != "" || config.LeaseSeconds < 7200 || config.LeaseSeconds > 86400 { - return nil, sandbox.ErrInvalid - } - return &Provider{config: config, client: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("E2B redirects are not allowed") }}}, nil -} -func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - info := sandbox.Info{Reference: b.Reference} - u, e := url.Parse(b.CoreURL) - if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" || (b.NetworkAccess != "enabled" && b.NetworkAccess != "disabled" && b.NetworkAccess != "") { - return info, sandbox.ErrInvalid - } - existing, e := p.allocations(ctx, b.Reference) - if e != nil { - return info, e - } - if len(existing) != 0 { - return info, sandbox.ErrExists - } - var a allocation - metadata := p.metadata(b.Reference) - metadata[metadataPrefix+"session"] = b.SessionID - metadata[metadataPrefix+"device"] = b.DeviceID - _, e = p.request(ctx, http.MethodPost, "/sandboxes", map[string]any{"templateID": p.config.Template, "timeout": p.config.LeaseSeconds, "secure": true, "autoPause": false, "allowInternetAccess": true, "metadata": metadata}, &a) - if e != nil { - return info, e - } - info.ProviderID = a.ID - // Re-read authenticated identity and envd authority. Running compute is not - // evidence that credential injection and daemon launch have completed. - a, e = p.inspectID(ctx, a.ID, b.Reference) - if e != nil { - return info, e - } - if e = p.bootstrap(ctx, a, b); e != nil { - return info, e - } - return p.GetInfo(ctx, b.Reference) -} -func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - info := sandbox.Info{Reference: r} - a, e := p.inspect(ctx, r) - if e != nil { - return info, e - } - info.ProviderID, info.State = a.ID, a.State - if a.State == "running" { - info.BootstrapComplete, e = p.completed(ctx, a, r) - } - return info, e -} -func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { - info := sandbox.Info{Reference: r} - a, e := p.inspect(ctx, r) - if e != nil { - return info, e - } - info.ProviderID, info.State = a.ID, a.State - if a.State != "running" { - return info, errors.New("E2B allocation is not running") - } - _, e = p.request(ctx, http.MethodPost, "/sandboxes/"+url.PathEscape(a.ID)+"/timeout", map[string]int{"timeout": p.config.LeaseSeconds}, nil) - if e != nil { - return info, e - } - return p.GetInfo(ctx, r) -} -func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { - all, e := p.allocations(ctx, r) - if e != nil { - return e - } - // An anomalous duplicate may be reclaimed only after all exact owners are - // checked. No execution operation chooses an arbitrary duplicate. - for _, a := range all { - if _, e = p.inspectID(ctx, a.ID, r); e != nil && !errors.Is(e, sandbox.ErrNotFound) { - return e - } - } - for _, a := range all { - if _, e = p.request(ctx, http.MethodDelete, "/sandboxes/"+url.PathEscape(a.ID), nil, nil); e != nil && !errors.Is(e, sandbox.ErrNotFound) { - return e - } - } - remaining, e := p.allocations(ctx, r) - if e != nil { - return e - } - if len(remaining) != 0 { - return errors.New("E2B removal unconfirmed") - } - return nil -} diff --git a/services/agents-api/internal/sandbox/e2b/provider_real_test.go b/services/agents-api/internal/sandbox/e2b/provider_real_test.go deleted file mode 100644 index 9d77cce67..000000000 --- a/services/agents-api/internal/sandbox/e2b/provider_real_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package e2b - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - "os" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" -) - -// This suite uses actual E2B VMs. Public real-model acceptance is separate; a -// completed bootstrap intentionally does not assert that its daemon authenticated. -func TestRealE2BLifecycle(t *testing.T) { - keyFile, template := os.Getenv("PARSAR_E2B_TEST_KEY_FILE"), os.Getenv("PARSAR_E2B_TEST_TEMPLATE") - if keyFile == "" || template == "" { - t.Skip("explicit real E2B account and qualified pinned template required") - } - key, e := os.ReadFile(keyFile) - if e != nil { - t.Fatal("cannot read private E2B key") - } - config := Config{InstallationID: uuid.NewString(), APIKey: strings.TrimSpace(string(key)), Template: template, LeaseSeconds: 7200} - p, e := New(config) - if e != nil { - t.Fatal(e) - } - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) - defer cancel() - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://example.com/api/v1", Credential: uuid.NewString(), NetworkAccess: "enabled"} - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - if e := p.Kill(cleanup, b.Reference); e != nil { - t.Error("real E2B cleanup", e) - } - }) - info, e := p.Create(ctx, b) - if e != nil { - t.Fatal("real create", e) - } - if info.ProviderID == "" || info.State != "running" || !info.BootstrapComplete { - t.Fatal("incomplete real bootstrap") - } - t.Log("real Create and completed bootstrap") - for _, input := range [][]byte{{}, bytes.Repeat([]byte{0, 255, 10, 1, 42}, 10485760)} { - result, err := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/bin/sh", "-c", "head -c 131072 /dev/zero; sha256sum"}, Stdin: input}) - digest := sha256.Sum256(input) - if err != nil || result.ExitCode != 0 || !strings.HasSuffix(result.Stdout, hex.EncodeToString(digest[:])+" -\n") || len(result.Stdout) != 131072+68 { - t.Fatalf("real stdin/EOF failure: input=%d stdout=%d exit=%d error=%v", len(input), len(result.Stdout), result.ExitCode, err) - } - } - t.Log("real binary stdin, concurrent output and EOF") - protection, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", `import os, subprocess -for path in ['/usr/local/bin/parsar-daemon', '/opt/parsar-e2b/init.py', '/usr/bin/envd']: - assert os.stat(path).st_uid == 0 and os.stat(path).st_mode & 0o022 == 0 - try: - fd = os.open(path, os.O_WRONLY | os.O_APPEND) - except PermissionError: - pass - else: - os.close(fd) - raise AssertionError('runtime can modify trusted code') -for path in ['/usr/local', '/usr/local/bin', '/opt/parsar-e2b']: - try: - fd = os.open(path + '/e2b-unsafe-write', os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - except PermissionError: - pass - else: - os.close(fd) - raise AssertionError('runtime can replace trusted code') -r = subprocess.run(['su', 'user', '-c', 'id -u'], input='', text=True, capture_output=True, timeout=5) -assert r.returncode != 0, 'runtime can assume the passwordless privileged account' -print('protected')`}}) - if e != nil || protection.ExitCode != 0 || protection.Stdout != "protected\n" { - t.Fatal("real Runtime executable/account protection failed", e) - } - t.Log("real unprivileged writes and privileged account transition denied") - fresh, e := New(config) - if e != nil { - t.Fatal(e) - } - observed, e := fresh.GetInfo(ctx, b.Reference) - if e != nil || observed != info { - t.Fatal("fresh provider lost allocation", e) - } - if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { - t.Fatal("duplicate allocation admitted", e) - } - foreign := b.Reference - foreign.TenantID = uuid.NewString() - if _, e = p.GetInfo(ctx, foreign); !errors.Is(e, sandbox.ErrNotFound) { - t.Fatal("foreign inspection", e) - } - if _, e = p.RunCommand(ctx, foreign, sandbox.Command{Args: []string{"/usr/bin/id"}}); !errors.Is(e, sandbox.ErrNotFound) { - t.Fatal("foreign initialization", e) - } - if e = p.Kill(ctx, foreign); e != nil { - t.Fatal(e) - } - if _, e = p.GetInfo(ctx, b.Reference); e != nil { - t.Fatal("foreign cleanup changed owner", e) - } - t.Log("restart lookup, duplicate prevention and foreign ownership") - var before, after struct { - End time.Time `json:"endAt"` - } - _, e = p.request(ctx, http.MethodGet, "/sandboxes/"+info.ProviderID, nil, &before) - if e != nil { - t.Fatal(e) - } - renewed, e := p.Renew(ctx, b.Reference) - if e != nil || renewed.ProviderID != info.ProviderID { - t.Fatal("renew replaced allocation", e) - } - _, e = p.request(ctx, http.MethodGet, "/sandboxes/"+info.ProviderID, nil, &after) - if e != nil || !after.End.After(before.End) { - t.Fatal("lease did not advance", e) - } - result, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", "import os,sys; print(os.getuid()); print(sys.argv[1]); print('stderr-proof',file=sys.stderr); sys.exit(7)", "literal;$(not-a-shell)"}, Directory: "/workspace"}) - if e != nil || result.ExitCode != 7 || result.Stdout != "1000\nliteral;$(not-a-shell)\n" || result.Stderr != "stderr-proof\n" { - t.Fatal("real argv/user/exit/output differ", e) - } - t.Log("real Renew and unprivileged argv-preserving RunCommand") - a, e := p.inspect(ctx, b.Reference) - if e != nil { - t.Fatal(e) - } - if _, e = p.file(ctx, a, bootstrapReceipt, []byte(`{"TenantID":"foreign"}`)); e != nil { - t.Fatal(e) - } - if _, e = p.GetInfo(ctx, b.Reference); !errors.Is(e, sandbox.ErrOwnership) { - t.Fatal("bad bootstrap receipt accepted", e) - } - uncertain, stop := context.WithTimeout(ctx, 5*time.Second) - _, e = p.RunCommand(uncertain, b.Reference, sandbox.Command{Args: []string{"/usr/bin/python3", "-c", "import time; open('/workspace/command-started','w').write('started'); time.sleep(30)"}}) - stop() - if !errors.Is(e, sandbox.ErrCommandUnconfirmed) && !errors.Is(e, context.DeadlineExceeded) { - t.Fatal("uncertain command reported success", e) - } - started, readErr := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"/bin/cat", "/workspace/command-started"}}) - if readErr != nil || started.Stdout != "started" { - t.Fatal("timeout fixture never started its actual process", readErr) - } - if e = p.Kill(ctx, b.Reference); e != nil { - t.Fatal("real Kill", e) - } - if _, e = p.GetInfo(ctx, b.Reference); !errors.Is(e, sandbox.ErrNotFound) { - t.Fatal("removal not confirmed", e) - } - if e = p.Kill(ctx, b.Reference); e != nil { - t.Fatal("repeated Kill", e) - } - t.Log("invalid receipt, uncertain command and confirmed idempotent cleanup") - - // Model a lost Create acknowledgement with a real cloud allocation that has - // never received bootstrap. Observation must not launch a daemon or recreate it. - b.AllocationID = uuid.NewString() - metadata := p.metadata(b.Reference) - var partial allocation - _, e = p.request(ctx, http.MethodPost, "/sandboxes", map[string]any{"templateID": config.Template, "timeout": 120, "secure": true, "metadata": metadata}, &partial) - if e != nil { - t.Fatal(e) - } - pending, e := fresh.GetInfo(ctx, b.Reference) - if e != nil || pending.ProviderID != partial.ID || pending.BootstrapComplete { - t.Fatal("running VM mistaken for initialized Runtime", e) - } - if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { - t.Fatal("unconfirmed Create replayed", e) - } - if e = p.Kill(ctx, b.Reference); e != nil { - t.Fatal(e) - } - t.Log("lost response recovery observes incomplete bootstrap without replay") -} diff --git a/services/agents-api/internal/store/artifact_capture.go b/services/agents-api/internal/store/artifact_capture.go index 4eeab6bb5..ab8c9a152 100644 --- a/services/agents-api/internal/store/artifact_capture.go +++ b/services/agents-api/internal/store/artifact_capture.go @@ -43,7 +43,7 @@ func (s *Store) StageTurnArtifacts(ctx context.Context, tenantID, sessionID, tur if err := json.Unmarshal(owned.Configuration, &configuration); err != nil { return err } - if configuration.Type != "openai_hosted" { + if configuration.Type != "openai_hosted" && configuration.Type != "self_hosted" { return ErrInvalidInput } tx, err := s.pool.Begin(ctx) diff --git a/services/agents-api/internal/store/devices.go b/services/agents-api/internal/store/devices.go index 71e8eec1d..7ac67e0ad 100644 --- a/services/agents-api/internal/store/devices.go +++ b/services/agents-api/internal/store/devices.go @@ -56,7 +56,7 @@ func newDeviceParams(tenant pgtype.UUID, name, credentialHash string) (sqlc.Crea return sqlc.CreateDeviceParams{}, fmt.Errorf("%w: device name and SHA-256 credential digest required", ErrInvalidInput) } return sqlc.CreateDeviceParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Name: name, CredentialHash: hex.EncodeToString(digest)}, nil + Name: name, CredentialHash: pgtype.Text{String: hex.EncodeToString(digest), Valid: true}}, nil } // GetDeviceCredential is used only by the shared gateway's credential verifier. @@ -178,7 +178,14 @@ func (s *Store) TouchRuntimeHeartbeat(ctx context.Context, deviceID string) (dev } func (s *Store) TouchAgentDaemonHeartbeat(ctx context.Context, heartbeat device.Heartbeat) (device.HeartbeatStatus, error) { - return s.TouchRuntimeHeartbeat(ctx, heartbeat.RuntimeID) + id, err := parseID(heartbeat.RuntimeID) + if err != nil { + return device.HeartbeatStatus{}, err + } + n, err := s.queries.TouchAuthenticatedDevice(ctx, sqlc.TouchAuthenticatedDeviceParams{ + ID: id, CredentialHash: heartbeat.CredentialHash, + }) + return device.HeartbeatStatus{Liveness: "online", Deleted: n == 0}, err } // Live connectivity belongs to the gateway Registry. Only last-seen time is diff --git a/services/agents-api/internal/store/devices_test.go b/services/agents-api/internal/store/devices_test.go index 399b48efc..44e077532 100644 --- a/services/agents-api/internal/store/devices_test.go +++ b/services/agents-api/internal/store/devices_test.go @@ -135,11 +135,11 @@ func TestStandaloneGatewayUsesExecutionCredentials(t *testing.T) { } } u, _ := url.Parse(wsURL) - q := url.Values{"device_id": {a.ID}, "token": {secret}, "version": {proto.Version}} + q := url.Values{"device_id": {a.ID}, "version": {proto.Version}} u.RawQuery = q.Encode() connect := func() *websocket.Conn { t.Helper() - conn, response, err := websocket.DefaultDialer.Dial(u.String(), nil) + conn, response, err := websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": {"Bearer " + secret}}) if response != nil { response.Body.Close() } diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/agents-api/internal/store/dispatch_test.go index 8a91f365e..699274d75 100644 --- a/services/agents-api/internal/store/dispatch_test.go +++ b/services/agents-api/internal/store/dispatch_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "net/http/httptest" "net/url" "sync" @@ -21,16 +22,17 @@ import ( ) type dispatchHarness struct { - t *testing.T - s *store.Store - d *execution.Dispatcher - tenant string - session store.Session - device store.ExecutionDevice - conn *websocket.Conn - registry *gateway.Registry - url string - credential string + t *testing.T + s *store.Store + d *execution.Dispatcher + tenant string + session store.Session + device store.ExecutionDevice + conn *websocket.Conn + registry *gateway.Registry + url string + credential string + environments map[string]*dispatchHarness } func newDispatchHarness(t *testing.T) *dispatchHarness { @@ -41,7 +43,7 @@ func newDispatchHarness(t *testing.T) *dispatchHarness { func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool) *dispatchHarness { t.Helper() s, _ := store.NewTestStore(t) - h := &dispatchHarness{t: t, s: s, tenant: uuid.NewString()} + h := &dispatchHarness{t: t, s: s, tenant: uuid.NewString(), environments: map[string]*dispatchHarness{}} ctx := context.Background() var err error h.session, err = s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "session", Configuration: configuration}) @@ -50,7 +52,16 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool } secret := uuid.NewString() h.credential = secret - if local { + var snapshot struct { + Environment struct { + Type string `json:"type"` + } `json:"environment"` + } + _ = json.Unmarshal(configuration, &snapshot) + if snapshot.Environment.Type == "self_hosted" { + h.device, h.credential = enrollFixtureSession(t, s, h.tenant, h.session) + secret = h.credential + } else if local { environment, getErr := s.GetSessionEnvironment(ctx, h.tenant, h.session.ID) if getErr != nil { t.Fatal(getErr) @@ -75,8 +86,8 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool h.url = server.URL t.Cleanup(func() { server.Close(); runtime.CloseConnections(h.registry) }) u, _ := url.Parse(wsURL) - u.RawQuery = url.Values{"device_id": {h.device.ID}, "token": {secret}, "version": {proto.Version}}.Encode() - h.conn, _, err = websocket.DefaultDialer.Dial(u.String(), nil) + u.RawQuery = url.Values{"device_id": {h.device.ID}, "version": {proto.Version}}.Encode() + h.conn, _, err = websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": {"Bearer " + secret}}) if err != nil { t.Fatal("device connection failed") } diff --git a/services/agents-api/internal/store/environment_admission_test.go b/services/agents-api/internal/store/environment_admission_test.go index 24582f227..4ad137bd2 100644 --- a/services/agents-api/internal/store/environment_admission_test.go +++ b/services/agents-api/internal/store/environment_admission_test.go @@ -34,11 +34,12 @@ func newEnvironmentAdmission(t *testing.T) (*dispatchHarness, *execution.Worker) }) h.session, err = worker.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{ Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), - Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/remote"}}`), + Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), }) if err != nil { t.Fatal(err) } + h = connectFixtureRuntime(t, h, h.session) return h, worker } @@ -152,10 +153,12 @@ func TestEnvironmentAdmissionWaitsForPreparedClaimAndRetainsRetry(t *testing.T) h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) } h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "admitted-native"}}) + completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != store.TurnCompleted { t.Fatal("completion", run.Turn) } + assertPreparationReleased(t, h, frame.ID, handle) replay, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "wait", inputs) if err != nil || len(replay) != 2 || !replay[0].Replayed || replay[0].TurnID != start.RunID { t.Fatal("terminal retry", replay, err) diff --git a/services/agents-api/internal/store/environment_connection_worker_test.go b/services/agents-api/internal/store/environment_connection_worker_test.go index 3d011b384..9d5edf973 100644 --- a/services/agents-api/internal/store/environment_connection_worker_test.go +++ b/services/agents-api/internal/store/environment_connection_worker_test.go @@ -12,7 +12,7 @@ import ( "github.com/google/uuid" ) -func TestEnvironmentConnectionWorkerReconcilesAndClosesBeforeLease(t *testing.T) { +func TestEnvironmentConnectionWorkerReconcilesAndReleasesLease(t *testing.T) { s, pool := store.NewTestStore(t) tenant := uuid.NewString() session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "connection-worker", Configuration: []byte(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) @@ -39,19 +39,8 @@ func TestEnvironmentConnectionWorkerReconcilesAndClosesBeforeLease(t *testing.T) t.Fatal(err) } awaitRelease() - var worker *execution.Worker - closed := false - next := uuid.NewString() - dispatcher := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), CloseEnvironmentConnections: func() { - closed = true - if err := worker.CheckOwnership(context.Background()); err != nil { - t.Error("shutdown lost ownership before connections", err) - } - if err := worker.ObserveEnvironmentConnection(context.Background(), tenant, environment.ID, next, 2, false); err != nil { - t.Error(err) - } - }} - worker, err = execution.StartWorker(t.Context(), dispatcher) + dispatcher := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry()} + worker, err := execution.StartWorker(t.Context(), dispatcher) if err != nil { t.Fatal(err) } @@ -68,12 +57,7 @@ func TestEnvironmentConnectionWorkerReconcilesAndClosesBeforeLease(t *testing.T) } }) awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") - if err := worker.ReplaceEnvironmentConnection(ctx, tenant, environment.ID, next); err != nil { - t.Fatal(err) - } - if err := worker.ObserveEnvironmentConnection(ctx, tenant, environment.ID, next, 1, true); err != nil { - t.Fatal(err) - } + cancel() select { case err := <-done: @@ -83,14 +67,11 @@ func TestEnvironmentConnectionWorkerReconcilesAndClosesBeforeLease(t *testing.T) case <-time.After(10 * time.Second): t.Fatal("worker did not close") } - if !closed { - t.Fatal("worker skipped connection shutdown") - } awaitEnvironmentConnectionState(t, t.Context(), s, tenant, environment.ID, "disconnected") if err := worker.CheckOwnership(t.Context()); err == nil { t.Fatal("worker retained lease") } - if len(retainedEnvironmentEvents(t, t.Context(), s, tenant, session.ID, environment.ID)) != 4 { + if len(retainedEnvironmentEvents(t, t.Context(), s, tenant, session.ID, environment.ID)) != 2 { t.Fatal("worker lifecycle did not retain all snapshots") } } diff --git a/services/agents-api/internal/store/environment_device_test.go b/services/agents-api/internal/store/environment_device_test.go index 06b8cd887..0901946b1 100644 --- a/services/agents-api/internal/store/environment_device_test.go +++ b/services/agents-api/internal/store/environment_device_test.go @@ -2,105 +2,70 @@ package store_test import ( "errors" - "net/url" "testing" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/gorilla/websocket" ) func TestWorkerEnvironmentSelectsCapableDeviceWithoutMovingBinding(t *testing.T) { - for _, missing := range []string{"preparation", "remote_environment", "durable_input_receipts"} { + for _, missing := range []string{"preparation", "local_environment", "durable_input_receipts"} { t.Run(missing, func(t *testing.T) { h := newDispatchHarness(t) _, pool := store.NewTestStore(t) - released := enableWorkerEnvironment(t, h) + enableWorkerEnvironment(t, h) + pending := unboundWorkerEnvironmentReservation(t, h) + bound := workerEnvironmentReservation(t, h) + originalRuntime := h.environments[bound.SessionID] caps := workerEnvironmentCapabilities() caps.Preparation = missing != "preparation" - caps.RemoteEnvironment = missing != "remote_environment" + caps.LocalEnvironment = missing != "local_environment" caps.DurableInputReceipts = missing != "durable_input_receipts" - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) - awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "reduced device capabilities", func() bool { - peer, err := h.registry.LookupDevice(h.device.ID) - if err != nil { - return false - } - info, _, _ := peer.AgentKindStatus("codex") - return info.Capabilities.Preparation == caps.Preparation && info.Capabilities.RemoteEnvironment == caps.RemoteEnvironment && info.Capabilities.DurableInputReceipts == caps.DurableInputReceipts - }) - pending := unboundWorkerEnvironmentReservation(t, h) - bound := workerEnvironmentReservation(t, h) - frames := workerFrames(t, h) + awaitFixtureCapabilities(t, originalRuntime, caps) + generalFrames := workerFrames(t, h) + boundFrames := workerFrames(t, originalRuntime) _, stop := startEnvironmentExpiryWorker(t, h.d) select { - case frame := <-frames: - t.Fatal("incapable device received work", frame.Type) + case frame := <-generalFrames: + t.Fatal("general device received self-hosted work", frame.Type) + case frame := <-boundFrames: + t.Fatal("incapable enrolled device received work", frame.Type) case <-time.After(time.Second): } if _, err := h.s.GetSessionDevice(t.Context(), h.tenant, pending.SessionID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("incapable device was bound", err) + t.Fatal("unregistered Runtime was assigned general compute", err) + } + session, err := h.s.GetSession(t.Context(), h.tenant, pending.SessionID) + if err != nil { + t.Fatal(err) } - other := connectWorkerEnvironmentDevice(t, h) + other := connectFixtureRuntime(t, h, session) otherFrames := workerFrames(t, other) request := nextWorkerFrame(t, otherFrames, proto.TypeExecutionPrepare) selected, err := h.s.GetSessionDevice(t.Context(), h.tenant, pending.SessionID) if err != nil || selected.ID != other.device.ID { - t.Fatal("eligible device was not bound before preparation", selected, err) + t.Fatal("enrollment did not retain exact Runtime", err) } original, err := h.s.GetSessionDevice(t.Context(), h.tenant, bound.SessionID) - if err != nil || original.ID != h.device.ID { - t.Fatal("existing binding was moved", original, err) + if err != nil || original.ID != originalRuntime.device.ID { + t.Fatal("existing binding moved to a capable Runtime", err) } handle := acknowledgePreparation(other, request.ID) other.write(request.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) - nextWorkerFrame(t, otherFrames, proto.TypeExecutionRelease) - stop() - if released.Load() != 1 { - t.Fatal("preparation owner not released") + release := nextWorkerFrame(t, otherFrames, proto.TypeExecutionRelease) + var released proto.ExecutionReleasePayload + if release.ID != request.ID || release.DecodePayload(&released) != nil || released.Handle != handle { + t.Fatal("preparation owner was not released") } + stop() for _, value := range []store.EnvironmentInputReservation{pending, bound} { stored, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, value.SessionID, value.ID) if err != nil || stored.State != store.EnvironmentInputPending || !stored.Deadline.Equal(value.Deadline) { - t.Fatal("device selection changed pending input", stored, err) + t.Fatal("device readiness changed pending input", err) } assertEnvironmentExpiryHasNoHistory(t, pool, value.SessionID) } }) } } - -func connectWorkerEnvironmentDevice(t *testing.T, h *dispatchHarness) *dispatchHarness { - t.Helper() - other := *h - other.credential = uuid.NewString() - var err error - other.device, err = h.s.CreateDevice(t.Context(), h.tenant, "capable alternative", device.HashCredential(other.credential)) - if err != nil { - t.Fatal(err) - } - u, err := url.Parse(h.url) - if err != nil { - t.Fatal(err) - } - u.Scheme, u.Path = "ws", "/api/v1/agent-daemon/ws" - u.RawQuery = url.Values{"device_id": {other.device.ID}, "token": {other.credential}, "version": {proto.Version}}.Encode() - other.conn, _, err = websocket.DefaultDialer.Dial(u.String(), nil) - if err != nil { - t.Fatal("alternative device connection failed") - } - t.Cleanup(func() { _ = other.conn.Close() }) - other.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: workerEnvironmentCapabilities()}}}) - awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "alternative device capabilities", func() bool { - peer, err := h.registry.LookupDevice(other.device.ID) - if err != nil { - return false - } - info, found, known := peer.AgentKindStatus("codex") - return known && found && info.Capabilities.Preparation - }) - return &other -} diff --git a/services/agents-api/internal/store/environment_directory_active_test.go b/services/agents-api/internal/store/environment_directory_active_test.go index 4aac5abe3..096968f1c 100644 --- a/services/agents-api/internal/store/environment_directory_active_test.go +++ b/services/agents-api/internal/store/environment_directory_active_test.go @@ -1,17 +1,15 @@ package store_test import ( - "context" "testing" - "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { - h, w, environment, released := directoryWorker(t, true) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true, WorkspaceReadPreparation: true}}}}) + h, w, environment := directoryWorker(t, true) + awaitFixtureCapabilities(t, h, workerEnvironmentCapabilities()) pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "execute", []store.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}) if err != nil { t.Fatal(err) @@ -36,13 +34,11 @@ func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { if got := awaitDirectoryResult(t, result); got.err != nil || len(got.value.Entries) != 1 { t.Fatal("active read", got.err) } - if released.Load() != 0 { - t.Fatal("active read released model execution") - } h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != store.TurnCompleted { t.Fatal("active read changed Turn outcome") } - awaitDaemonRemoteCondition(t, context.Background(), 3*time.Second, "execution credential release", func() bool { return released.Load() == 1 }) + assertPreparationReleased(t, h, prepare.ID, handle) } diff --git a/services/agents-api/internal/store/environment_directory_native_test.go b/services/agents-api/internal/store/environment_directory_native_test.go deleted file mode 100644 index 2024ba5bf..000000000 --- a/services/agents-api/internal/store/environment_directory_native_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "maps" - "os" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestNativePreparedWorkerDirectory(t *testing.T) { - testNativePreparedWorkerRemoteEnvironment(t, true) -} - -func prepareWorkerDirectoryArtifact(t *testing.T, native string) *nativeHarnessArtifact { - t.Helper() - artifact, helper := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT"), os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") - if !filepath.IsAbs(artifact) || !filepath.IsAbs(helper) { - t.Skip("qualified private harness and directory helper required") - } - t.Setenv("PARSAR_CODEX_HARNESS_BIN", artifact) - t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/usr/local/bin/agents-api-codex-directory") - return newNativeHarnessArtifact(t, native, artifact) -} - -func verifyWorkerDirectoryReads(t *testing.T, ctx context.Context, h *dispatchHarness, w *execution.Worker, registry *codex.Registry, environment store.Environment, root string) map[string]any { - t.Helper() - wait := func() { - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor after directory release", func() bool { - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - return err == nil && connected - }) - } - wait() - stable := filepath.Join(root, "parsar-daemon", "agent-sessions", "agents-api-"+h.session.ID) - before := nativeReadStateHashes(t, stable) - session, err := h.s.GetSession(ctx, h.tenant, h.session.ID) - if err != nil || session.LastTurn == nil { - t.Fatal("missing completed native Turn") - } - result, err := w.ReadEnvironmentDirectory(ctx, environment, "") - if err != nil || result.Truncated { - t.Fatal("Core native directory read failed", err) - } - found := false - for _, entry := range result.Entries { - if entry.Name == "retained.txt" && entry.Kind == "file" && entry.SizeBytes != nil && *entry.SizeBytes == int64(len("remote-file-content\n")) { - found = true - } - } - if !found { - t.Fatal("Core directory omitted the real-model generated file") - } - if _, err := w.ReadEnvironmentDirectory(ctx, environment, "missing-directory"); !errors.Is(err, store.ErrNotFound) { - t.Fatal("Core missing directory result", err) - } - wait() - after, err := h.s.GetSession(ctx, h.tenant, h.session.ID) - if err != nil || after.LastTurn == nil || after.LastTurn.ID != session.LastTurn.ID || after.LastTurn.Status != store.TurnCompleted || !maps.Equal(before, nativeReadStateHashes(t, stable)) { - t.Fatal("directory read changed execution history or configuration") - } - remaining, err := filepath.Glob(filepath.Join(root, "parsar-daemon", "workspace-read", "read-*")) - if err != nil || len(remaining) != 0 { - t.Fatal("Core read returned before temporary state removal") - } - return map[string]any{"real_model_file_observed": true, "missing_directory_verified": true, "stable_state_unchanged": true, "turn_unchanged": true, "temporary_state_removed": true} -} diff --git a/services/agents-api/internal/store/environment_directory_test.go b/services/agents-api/internal/store/environment_directory_test.go index 03b299b57..34d8389fd 100644 --- a/services/agents-api/internal/store/environment_directory_test.go +++ b/services/agents-api/internal/store/environment_directory_test.go @@ -3,7 +3,6 @@ package store_test import ( "context" "errors" - "sync/atomic" "testing" "time" @@ -18,19 +17,14 @@ type directoryResult struct { err error } -func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *execution.Worker, store.Environment, *atomic.Int32) { +func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *execution.Worker, store.Environment) { t.Helper() - h := newDispatchHarness(t) - var err error - h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "read-only", Configuration: []byte(`{"agent":{"model":"unavailable-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) - if err != nil { - t.Fatal(err) - } + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"unavailable-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), true) environment, err := h.s.GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) if err != nil { t.Fatal(err) } - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{RemoteEnvironment: true, Preparation: true, WorkspaceReadPreparation: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, Preparation: true, WorkspaceReadPreparation: true}}}}) peer, err := h.registry.LookupDevice(h.device.ID) if err != nil { t.Fatal(err) @@ -39,7 +33,6 @@ func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *executio info, _, _ := peer.AgentKindStatus("codex") return info.Capabilities.WorkspaceReadPreparation }) - released := &atomic.Int32{} h.d.Options = func(context.Context, store.Session) (map[string]any, error) { if len(execute) > 0 && execute[0] { return nil, nil @@ -47,12 +40,6 @@ func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *executio t.Error("read resolved model credentials") return nil, errors.New("no credentials") } - h.d.EnvironmentConnection = func(ctx context.Context, s store.Session, e store.Environment) (execution.EnvironmentConnection, error) { - if ctx.Err() != nil || s.ID != h.session.ID || e.ID != environment.ID || e.TenantID != h.tenant { - t.Error("wrong reader binding") - } - return execution.EnvironmentConnection{URL: "http://read-transport.test", Token: "synthetic-read-token", Release: func() { released.Add(1) }}, nil - } w, err := execution.StartWorker(t.Context(), h.d) if err != nil { t.Fatal(err) @@ -68,7 +55,7 @@ func directoryWorker(t *testing.T, execute ...bool) (*dispatchHarness, *executio t.Error("reader worker did not stop") } }) - return h, w, environment, released + return h, w, environment } func startDirectoryRead(ctx context.Context, w *execution.Worker, environment store.Environment) <-chan directoryResult { @@ -95,7 +82,7 @@ func prepareDirectoryRead(t *testing.T, h *dispatchHarness, environment store.En t.Helper() frame := h.read(proto.TypeExecutionPrepare) var request proto.ExecutionPreparePayload - if frame.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.RemoteEnvironment.ID != environment.ID || request.Configuration.AgentStateKey != "agents-api-"+h.session.ID { + if frame.DecodePayload(&request) != nil || !proto.ValidWorkspaceReadPreparation(request.Configuration) || request.Configuration.LocalEnvironment == nil || request.Configuration.LocalEnvironment.ID != environment.ID || request.Configuration.AgentStateKey != "agents-api-"+h.session.ID { t.Fatal("read did not use the closed preparation profile") } handle := acknowledgePreparation(h, frame.ID) @@ -125,7 +112,7 @@ func completeDirectoryRead(t *testing.T, h *dispatchHarness, request, read strin } func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing.T) { - h, w, environment, released := directoryWorker(t) + h, w, environment := directoryWorker(t) foreign := environment foreign.TenantID = uuid.NewString() if _, err := w.ReadEnvironmentDirectory(t.Context(), foreign, "reports"); !errors.Is(err, store.ErrNotFound) { @@ -148,8 +135,8 @@ func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing } completeDirectoryRead(t, h, request, read, false, false) got := awaitDirectoryResult(t, result) - if got.err != nil || len(got.value.Entries) != 1 || released.Load() != 1 { - t.Fatal("directory result", got.err, released.Load()) + if got.err != nil || len(got.value.Entries) != 1 { + t.Fatal("directory result", got.err) } session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) if err != nil || session.LastTurn != nil || session.EnvironmentInputActivity != nil { @@ -164,12 +151,12 @@ func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing func TestEnvironmentDirectoryWorkerRejectsIncompleteOrUnreleasedResults(t *testing.T) { for _, mode := range []string{"truncated", "cleanup_failed"} { t.Run(mode, func(t *testing.T) { - h, w, environment, released := directoryWorker(t) + h, w, environment := directoryWorker(t) result := startDirectoryRead(t.Context(), w, environment) request, read := prepareDirectoryRead(t, h, environment) completeDirectoryRead(t, h, request, read, mode == "truncated", mode == "cleanup_failed") got := awaitDirectoryResult(t, result) - if !errors.Is(got.err, execution.ErrExecutionUnavailable) || len(got.value.Entries) != 0 || released.Load() != 1 { + if !errors.Is(got.err, execution.ErrExecutionUnavailable) || len(got.value.Entries) != 0 { t.Fatal("incomplete reader exposed data or retained authority", got.err) } }) @@ -177,7 +164,7 @@ func TestEnvironmentDirectoryWorkerRejectsIncompleteOrUnreleasedResults(t *testi } func TestEnvironmentDirectorySequentialReadsReleaseSchedulingOwnership(t *testing.T) { - h, w, environment, released := directoryWorker(t) + h, w, environment := directoryWorker(t) const pages = 32 done := make(chan error, 1) go func() { @@ -200,8 +187,8 @@ func TestEnvironmentDirectorySequentialReadsReleaseSchedulingOwnership(t *testin } select { case err := <-done: - if err != nil || released.Load() != pages { - t.Fatal("sequential reads retained ownership", err, released.Load()) + if err != nil { + t.Fatal("sequential reads retained ownership", err) } case <-time.After(5 * time.Second): t.Fatal("sequential reads did not finish") @@ -209,7 +196,7 @@ func TestEnvironmentDirectorySequentialReadsReleaseSchedulingOwnership(t *testin } func TestEnvironmentDirectoryObserverCancellationRetainsReadOwner(t *testing.T) { - h, w, environment, released := directoryWorker(t) + h, w, environment := directoryWorker(t) ctx, cancel := context.WithCancel(t.Context()) result := startDirectoryRead(ctx, w, environment) request, read := prepareDirectoryRead(t, h, environment) @@ -217,12 +204,8 @@ func TestEnvironmentDirectoryObserverCancellationRetainsReadOwner(t *testing.T) if got := awaitDirectoryResult(t, result); !errors.Is(got.err, execution.ErrExecutionUnavailable) { t.Fatal("cancelled observer result", got.err) } - if released.Load() != 0 { - t.Fatal("observer cancelled native ownership") - } if _, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { t.Fatal("cancelled observer freed Session owner") } completeDirectoryRead(t, h, request, read, false, false) - awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "reader release", func() bool { return released.Load() == 1 }) } diff --git a/services/agents-api/internal/store/environment_files_native_test.go b/services/agents-api/internal/store/environment_files_native_test.go deleted file mode 100644 index 6a891ab1c..000000000 --- a/services/agents-api/internal/store/environment_files_native_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativePublicEnvironmentFiles(t *testing.T) { - binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - keyFile, python := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE"), os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") - if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" || python == "" || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" { - t.Skip("qualified Codex executor, directory artifacts, pinned SDK and real provider required") - } - version, err := exec.Command(binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native Codex 0.153.4 required") - } - keyBytes, err := os.ReadFile(keyFile) - if err != nil || strings.TrimSpace(string(keyBytes)) == "" { - t.Fatal("real model credential unavailable") - } - key := strings.TrimSpace(string(keyBytes)) - t.Setenv("PARSAR_CODEX_BIN", binary) - artifact := prepareWorkerDirectoryArtifact(t, binary) - h, ctx, root := nativeDispatchHarnessWithTimeout(t, 10*time.Minute) - second := &dispatchHarness{t: t, s: h.s, tenant: uuid.NewString(), credential: uuid.NewString(), registry: h.registry, url: h.url} - second.device, err = h.s.CreateDevice(ctx, second.tenant, "second isolated executor", device.HashCredential(second.credential)) - if err != nil { - t.Fatal(err) - } - secondRoot, err := os.MkdirTemp(os.Getenv("PARSAR_NATIVE_PROOF_DIR"), "execution-native-") - if err != nil { - t.Fatal(err) - } - startNativeDispatchDaemon(t, second, secondRoot, os.Getenv("PARSAR_NATIVE_DAEMON_BIN")) - peers, roots := []*dispatchHarness{h, second}, []string{root, secondRoot} - tokens := []string{uuid.NewString(), uuid.NewString()} - secrets := []string{key, h.credential, second.credential, tokens[0], tokens[1]} - var secretMu sync.Mutex - var registry *codex.Registry - h.d.Options = func(context.Context, store.Session) (map[string]any, error) { - return map[string]any{"codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, nil - } - h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { - token, release, err := registry.IssueHarnessCredential(owner, session.TenantID, environment.ID) - secretMu.Lock() - secrets = append(secrets, token) - secretMu.Unlock() - return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, err - } - h.d.CloseEnvironmentConnections = func() { - if registry != nil { - registry.Close() - } - } - worker, err := execution.StartWorker(ctx, h.d) - if err != nil { - t.Fatal(err) - } - workerCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - var started sync.Once - start := func() { started.Do(func() { go func() { done <- worker.Run(workerCtx) }() }) } - server := httptest.NewUnstartedServer(nil) - defer func() { - cancel() - start() - select { - case err := <-done: - if err != nil && err != context.Canceled { - t.Error("worker stopped unexpectedly", err) - } - case <-time.After(15 * time.Second): - t.Error("worker retained Files acceptance ownership") - } - if registry != nil { - registry.Close() - if registry.LifecycleError() != nil { - t.Error("registry lifecycle failed") - } - } - server.Close() - }() - registry, err = codex.New(codex.Config{Store: h.s, CheckOwnership: worker.CheckOwnership, ReplaceConnection: worker.ReplaceEnvironmentConnection, ObserveConnection: worker.ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - keys := make([]api.APIKey, 0, len(peers)) - for index, peer := range peers { - keys = append(keys, api.APIKey{OrganizationID: "test-org", ProjectID: peer.tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(tokens[index]), TenantID: peer.tenant}) - } - auth, err := api.NewAuthenticator(keys) - if err != nil { - t.Fatal(err) - } - public, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentRemoteURL(registry.PublicURL())) - if err != nil { - t.Fatal(err) - } - mux := http.NewServeMux() - mux.Handle("/cloud/environment/", registry.Handler()) - mux.Handle("/", public) - server.Config.Handler = mux - server.Start() - start() - tenants := make([]map[string]string, 0, len(peers)) - for index, peer := range peers { - workspace := "/parsar-public-files-" + uuid.NewString() - peer.session, err = createPublicFilesSession(ctx, peer, workspace) - if err != nil { - t.Fatal(err) - } - environment, err := h.s.GetSessionEnvironment(ctx, peer.tenant, peer.session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := h.s.IssueExecutorCredential(ctx, store.FixtureExecutorPrincipal(t, h.s, peer.tenant), uuid.NewString(), "") - if err != nil { - t.Fatal(err) - } - secretMu.Lock() - secrets = append(secrets, credential.Token) - secretMu.Unlock() - local := prepareDaemonRemoteWorkspace(t, roots[index], "FILES_"+uuid.NewString()) - artifact.container = startDaemonRemoteExecutor(t, ctx, roots[index], local, workspace, binary, image, registry.PublicURL(), environment.ID, credential) - artifact.installDirectoryHelper(t, ctx) - awaitEnvironmentConnectionState(t, ctx, h.s, peer.tenant, environment.ID, "connected") - tokenFile := filepath.Join(roots[index], "public-token") - if err := os.WriteFile(tokenFile, []byte(tokens[index]), 0600); err != nil { - t.Fatal(err) - } - tenants = append(tenants, map[string]string{"session_id": peer.session.ID, "token_file": tokenFile}) - } - settings, err := json.Marshal(map[string]any{"engine": "codex", "base": server.URL, "tenants": tenants}) - if err != nil { - t.Fatal(err) - } - command := exec.CommandContext(ctx, python, "../../tests/official_environment_files_native.py") - command.Stdin = bytes.NewReader(settings) - output, err := command.CombinedOutput() - secretMu.Lock() - for _, secret := range secrets { - if secret != "" && bytes.Contains(output, []byte(secret)) { - t.Error("credential appeared in public Files evidence") - output = bytes.ReplaceAll(output, []byte(secret), []byte("[REDACTED]")) - } - } - secretMu.Unlock() - if writeErr := os.WriteFile(filepath.Join(root, "public-files-evidence.json"), output, 0600); writeErr != nil { - t.Fatal(writeErr) - } - if err != nil || !json.Valid(output) { - t.Fatal("real public Files SDK/raw verification failed; inspect private evidence", root) - } - for _, home := range roots { - remaining, err := filepath.Glob(filepath.Join(home, "parsar-daemon", "workspace-read", "read-*")) - if err != nil || len(remaining) != 0 { - t.Fatal("public Files returned before temporary read cleanup") - } - } - t.Log("real two-tenant Files.list evidence", root) -} - -func createPublicFilesSession(ctx context.Context, h *dispatchHarness, workspace string) (store.Session, error) { - instructions := "Use the native shell for requested file operations. Do not delegate." - agent := v1.Agent{ID: "agent_" + uuid.NewString(), Model: "MiniMax-M3", Instructions: &instructions, - MultiAgent: v1.MultiAgentConfig{Enabled: false}, Reasoning: v1.Reasoning{}, ServiceTier: "auto", - Text: v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: "medium"}, Tools: []json.RawMessage{}} - configuration, err := json.Marshal(map[string]any{"agent": agent, "environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) - if err != nil { - return store.Session{}, err - } - return h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: configuration}) -} diff --git a/services/agents-api/internal/store/environment_runtime_fixture_test.go b/services/agents-api/internal/store/environment_runtime_fixture_test.go new file mode 100644 index 000000000..6187bec58 --- /dev/null +++ b/services/agents-api/internal/store/environment_runtime_fixture_test.go @@ -0,0 +1,125 @@ +package store_test + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func enrollFixtureSession(t *testing.T, s *store.Store, tenant string, session store.Session) (store.ExecutionDevice, string) { + t.Helper() + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + principal := store.FixtureExecutorPrincipal(t, s, tenant) + key, err := s.IssueExecutorCredential(t.Context(), principal, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + enrolled, err := s.EnrollRuntime(t.Context(), environment.ID, device.HashCredential(key.Token)) + if err != nil || enrolled.EnvironmentID != environment.ID || enrolled.SessionID != session.ID || enrolled.WorkspaceDirectory != "/workspace" { + t.Fatalf("Runtime enrollment: %+v %v", enrolled, err) + } + bound, err := s.GetSessionDevice(t.Context(), tenant, session.ID) + if err != nil || bound.ID != enrolled.DeviceID || bound.EnvironmentID != environment.ID { + t.Fatalf("Runtime binding: %+v %v", bound, err) + } + return bound, key.Token +} + +func connectFixtureRuntime(t *testing.T, h *dispatchHarness, session store.Session) *dispatchHarness { + t.Helper() + other := *h + other.session = session + other.device, other.credential = enrollFixtureSession(t, h.s, h.tenant, session) + u, err := url.Parse(h.url) + if err != nil { + t.Fatal(err) + } + u.Scheme, u.Path = "ws", "/api/v1/agent-daemon/ws" + u.RawQuery = url.Values{"device_id": {other.device.ID}, "version": {proto.Version}}.Encode() + other.conn, _, err = websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": {"Bearer " + other.credential}}) + if err != nil { + t.Fatal("enrolled Runtime connection failed") + } + t.Cleanup(func() { _ = other.conn.Close() }) + enableWorkerEnvironment(t, &other) + return &other +} + +func assertPreparationReleased(t *testing.T, h *dispatchHarness, request, handle string) { + t.Helper() + frame := h.read(proto.TypeExecutionRelease) + var release proto.ExecutionReleasePayload + if frame.ID != request || frame.DecodePayload(&release) != nil || release.Handle != handle { + t.Fatal("preparation owner was not released", frame.ID, release) + } +} + +// Completed local Turns export their outputs before publishing completion. +func completeEmptyArtifactExport(t *testing.T, h *dispatchHarness, frames ...<-chan proto.Envelope) { + t.Helper() + read := h.read + if len(frames) != 0 { + read = func(kind string) proto.Envelope { return nextWorkerFrame(t, frames[0], kind) } + } + frame := read(proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + environment, err := h.s.GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) + if err != nil || frame.DecodePayload(&prepare) != nil || !proto.ValidWorkspaceReadPreparation(prepare.Configuration) || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID { + t.Fatal("artifact preparation lost exact local authority", err) + } + handle := acknowledgePreparation(h, frame.ID) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + exportFrame := read(proto.TypeWorkspaceExport) + var request proto.WorkspaceExportPayload + if exportFrame.DecodePayload(&request) != nil || request.Step != "begin" || request.Handle != handle || request.EnvironmentID != environment.ID { + t.Fatalf("artifact export changed owner: request=%+v handle=%s environment=%s", request, handle, environment.ID) + } + // A closed empty tar is a valid output snapshot. + h.write(exportFrame.ID, proto.TypeWorkspaceExportResult, proto.WorkspaceExportResultPayload{Outcome: "chunk", Data: make([]byte, 1024)}) + next := read(proto.TypeWorkspaceExport) + if next.ID != exportFrame.ID || next.DecodePayload(&request) != nil || request.Step != "next" || request.Offset != 1024 { + t.Fatal("artifact export did not await final receipt") + } + h.write(exportFrame.ID, proto.TypeWorkspaceExportResult, proto.WorkspaceExportResultPayload{Outcome: "completed", Offset: 1024}) + releaseFrame := read(proto.TypeExecutionRelease) + var release proto.ExecutionReleasePayload + if releaseFrame.ID != frame.ID || releaseFrame.DecodePayload(&release) != nil || release.Handle != handle { + t.Fatal("artifact preparation was not released") + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "released"}) +} + +func assertNoRuntimeAllocation(t *testing.T, h *dispatchHarness) { + t.Helper() + _, pool := store.NewTestStore(t) + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE environment_id IN (SELECT id FROM environments WHERE session_id=$1)", h.session.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("self-hosted fixture allocated managed compute", count, err) + } +} + +func awaitFixtureCapabilities(t *testing.T, h *dispatchHarness, caps proto.AgentKindCapabilities) { + t.Helper() + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "updated Runtime capabilities", func() bool { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + return false + } + info, _, known := peer.AgentKindStatus("codex") + return known && info.Capabilities.Preparation == caps.Preparation && + info.Capabilities.LocalEnvironment == caps.LocalEnvironment && + info.Capabilities.DurableInputReceipts == caps.DurableInputReceipts && + info.Capabilities.WorkspaceOutputExport == caps.WorkspaceOutputExport + }) +} diff --git a/services/agents-api/internal/store/environment_work_test.go b/services/agents-api/internal/store/environment_work_test.go index 127ac0736..57e41aa83 100644 --- a/services/agents-api/internal/store/environment_work_test.go +++ b/services/agents-api/internal/store/environment_work_test.go @@ -3,7 +3,6 @@ package store_test import ( "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" ) @@ -35,21 +34,18 @@ func TestEnvironmentInputWorkFiltersAndPagesDevices(t *testing.T) { t.Fatal(err) } default: - other, err := h.s.CreateDevice(t.Context(), h.tenant, state, device.HashCredential(uuid.NewString())) - if err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "UPDATE session_devices SET device_id=$2 WHERE session_id=$1", pending.SessionID, other.ID); err != nil { - t.Fatal(err) - } + runtime := h.environments[pending.SessionID] if state == "revoked" { - if err := h.s.RevokeDevice(t.Context(), h.tenant, other.ID); err != nil { + if err := h.s.RevokeDevice(t.Context(), h.tenant, runtime.device.ID); err != nil { t.Fatal(err) } - work, err := h.s.ListEnvironmentInputWork(t.Context(), "", []string{other.ID}) + work, err := h.s.ListEnvironmentInputWork(t.Context(), "", []string{runtime.device.ID}) if err != nil || len(work) != 0 { - t.Fatal("revoked device selected", work, err) + t.Fatal("revoked Runtime selected", work, err) } + } else { + _ = runtime.conn.Close() + delete(h.environments, pending.SessionID) } } } @@ -64,7 +60,11 @@ func TestEnvironmentInputWorkFiltersAndPagesDevices(t *testing.T) { unboundWorkerEnvironmentReservation(t, &foreign) seen, cursor := 0, "" for _, count := range []int{100, 4, 0} { - work, err := h.s.ListEnvironmentInputWork(t.Context(), cursor, []string{h.device.ID}) + devices := []string{h.device.ID} + for _, runtime := range h.environments { + devices = append(devices, runtime.device.ID) + } + work, err := h.s.ListEnvironmentInputWork(t.Context(), cursor, devices) if err != nil || len(work) != count { t.Fatal("environment work page", len(work), count, err) } diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/agents-api/internal/store/environment_worker_helpers_test.go index 43a82957e..f107c9cf6 100644 --- a/services/agents-api/internal/store/environment_worker_helpers_test.go +++ b/services/agents-api/internal/store/environment_worker_helpers_test.go @@ -3,8 +3,6 @@ package store_test import ( "context" "encoding/json" - "sync" - "sync/atomic" "testing" "time" @@ -14,7 +12,7 @@ import ( "github.com/google/uuid" ) -func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) *atomic.Int32 { +func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) { t.Helper() h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: workerEnvironmentCapabilities()}}}) awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "worker preparation capability", func() bool { @@ -25,30 +23,26 @@ func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) *atomic.Int32 { info, _, _ := peer.AgentKindStatus("codex") return info.Capabilities.Preparation && info.Capabilities.EnvironmentNone }) - released := &atomic.Int32{} - h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { - var once sync.Once - return execution.EnvironmentConnection{URL: "http://private-registry.test", Token: "synthetic-worker-token", Release: func() { once.Do(func() { released.Add(1) }) }}, nil - } - return released } func workerEnvironmentCapabilities() proto.AgentKindCapabilities { - return proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true} + return proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, WorkspaceOutputExport: true} } func workerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { t.Helper() pending := unboundWorkerEnvironmentReservation(t, h) - if err := h.s.BindSessionDevice(t.Context(), h.tenant, pending.SessionID, h.device.ID); err != nil { + session, err := h.s.GetSession(t.Context(), h.tenant, pending.SessionID) + if err != nil { t.Fatal(err) } + h.environments[session.ID] = connectFixtureRuntime(t, h, session) return pending } func unboundWorkerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { t.Helper() - session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/remote"}}`)}) + session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) if err != nil { t.Fatal(err) } @@ -59,26 +53,42 @@ func unboundWorkerEnvironmentReservation(t *testing.T, h *dispatchHarness) store return pending } -func workerFrames(t *testing.T, h *dispatchHarness) <-chan proto.Envelope { +func workerFrames(t *testing.T, runtimes ...*dispatchHarness) <-chan proto.Envelope { t.Helper() frames := make(chan proto.Envelope, 64) - go func() { - defer close(frames) - for { - var env proto.Envelope - if h.conn.ReadJSON(&env) != nil { - return - } - select { - case frames <- env: - case <-t.Context().Done(): - return + for _, h := range runtimes { + go func() { + for { + var env proto.Envelope + if h.conn.ReadJSON(&env) != nil { + return + } + select { + case frames <- env: + case <-t.Context().Done(): + return + } } - } - }() + }() + } return frames } +func workerRuntimeForPreparation(t *testing.T, h *dispatchHarness, frame proto.Envelope) *dispatchHarness { + t.Helper() + var input proto.ExecutionPreparePayload + if frame.DecodePayload(&input) != nil { + t.Fatal("invalid worker preparation") + } + for _, candidate := range h.environments { + if input.Configuration.AgentStateKey == "agents-api-"+candidate.session.ID && input.Configuration.LocalEnvironment != nil && input.Configuration.LocalEnvironment.ID == candidate.device.EnvironmentID { + return candidate + } + } + t.Fatal("worker preparation escaped its enrolled Runtime") + return nil +} + func nextWorkerFrame(t *testing.T, frames <-chan proto.Envelope, kind string) proto.Envelope { t.Helper() select { diff --git a/services/agents-api/internal/store/environment_worker_test.go b/services/agents-api/internal/store/environment_worker_test.go index 313a58802..19b0c4baf 100644 --- a/services/agents-api/internal/store/environment_worker_test.go +++ b/services/agents-api/internal/store/environment_worker_test.go @@ -13,13 +13,17 @@ import ( func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { h := newDispatchHarness(t) _, pool := store.NewTestStore(t) - released := enableWorkerEnvironment(t, h) - frames := workerFrames(t, h) + enableWorkerEnvironment(t, h) pending := map[string]store.EnvironmentInputReservation{} for range 2 { - value := unboundWorkerEnvironmentReservation(t, h) + value := workerEnvironmentReservation(t, h) pending[value.SessionID] = value } + runtimes := []*dispatchHarness{h} + for _, runtime := range h.environments { + runtimes = append(runtimes, runtime) + } + frames := workerFrames(t, runtimes...) ordinary := map[string]store.Session{} for _, key := range []string{"one", "two", "three"} { session := publicSession(t, h, key) @@ -49,16 +53,18 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { t.Fatal("mixed queues did not share capacity", len(normal), len(preparing)) } first := preparing[0] - handle := acknowledgePreparation(h, first.ID) - h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + firstRuntime := workerRuntimeForPreparation(t, h, first) + handle := acknowledgePreparation(firstRuntime, first.ID) + firstRuntime.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) frame := nextWorkerFrame(t, frames, proto.TypeExecutionStart) var start proto.ExecutionStartPayload if frame.ID != first.ID || frame.DecodePayload(&start) != nil || start.Handle != handle || start.Prompt != "first" { t.Fatal("worker changed preparation at Start") } - h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + firstRuntime.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) second := preparing[1] - secondHandle := acknowledgePreparation(h, second.ID) + secondRuntime := workerRuntimeForPreparation(t, h, second) + secondHandle := acknowledgePreparation(secondRuntime, second.ID) var prepare proto.ExecutionPreparePayload if second.DecodePayload(&prepare) != nil { t.Fatal("invalid Prepare") @@ -78,40 +84,59 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { if _, err := h.s.CancelEnvironmentInput(t.Context(), h.tenant, waiting.SessionID, waiting.ID); err != nil { t.Fatal(err) } - release := nextWorkerFrame(t, frames, proto.TypeExecutionRelease) - var payload proto.ExecutionReleasePayload - if release.ID != second.ID || release.DecodePayload(&payload) != nil || payload.Handle != secondHandle { - t.Fatal("cancel released the wrong preparation") + // Distinct Runtime sockets do not promise cross-socket delivery order. + var release proto.Envelope + var resumed proto.Envelope + for range 2 { + select { + case frame := <-frames: + switch frame.Type { + case proto.TypeExecutionRelease: + if release.ID != "" { + t.Fatal("duplicate preparation release") + } + release = frame + case proto.TypePromptRequest: + if resumed.ID != "" { + t.Fatal("cleanup freed more than one capacity slot") + } + resumed = frame + default: + t.Fatal("unexpected cleanup frame", frame.Type) + } + case <-time.After(5 * time.Second): + t.Fatal("cancel did not release preparation and resume queued work") + } } - normal = append(normal, nextWorkerFrame(t, frames, proto.TypePromptRequest)) - if released.Load() != 1 { - t.Fatal("next job preceded preparation cleanup") + var payload proto.ExecutionReleasePayload + if release.ID != second.ID || release.DecodePayload(&payload) != nil || payload.Handle != secondHandle || resumed.ID == "" { + t.Fatal("cancel released the wrong preparation or lost queued work") } + normal = append(normal, resumed) for _, request := range normal { h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "ordinary complete"}) h.session = ordinary[request.ID] waitTurn(t, h, request.ID, store.TurnCompleted) } - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "remote complete"}) + firstRuntime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "local complete"}) + completeEmptyArtifactExport(t, firstRuntime, frames) nextWorkerFrame(t, frames, proto.TypeExecutionRelease) stop() - if released.Load() != 2 { - t.Fatal("connection owners were not released") - } assertEnvironmentExpiryHasNoHistory(t, pool, waiting.SessionID) assertEnvironmentExpiryHasNoHistory(t, pool, due.SessionID) } func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { h := newDispatchHarness(t) - released := enableWorkerEnvironment(t, h) - frames := workerFrames(t, h) - pending := unboundWorkerEnvironmentReservation(t, h) + enableWorkerEnvironment(t, h) + pending := workerEnvironmentReservation(t, h) + runtime := h.environments[pending.SessionID] + frames := workerFrames(t, h, runtime) _, stop := startEnvironmentExpiryWorker(t, h.d) first := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) started := time.Now() - handle := acknowledgePreparation(h, first.ID) - h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) + handle := acknowledgePreparation(runtime, first.ID) + runtime.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) nextWorkerFrame(t, frames, proto.TypeExecutionRelease) h.session = publicSession(t, h, "unrelated") receipt := h.message("ordinary", "make progress after preparation failure") @@ -122,10 +147,10 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) waitTurn(t, h, request.ID, store.TurnCompleted) second := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) - if time.Since(started) < 4*time.Second || first.ID == second.ID || released.Load() != 1 { + if time.Since(started) < 4*time.Second || first.ID == second.ID { t.Fatal("pending preparation retried rapidly or reused a released owner") } - acknowledgePreparation(h, second.ID) + acknowledgePreparation(runtime, second.ID) select { case frame := <-frames: t.Fatal("active preparation was duplicated", frame.Type) @@ -139,22 +164,20 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { } _, stop = startEnvironmentExpiryWorker(t, h.d) third := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) - handle = acknowledgePreparation(h, third.ID) - h.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) + handle = acknowledgePreparation(runtime, third.ID) + runtime.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) request = nextWorkerFrame(t, frames, proto.TypeExecutionStart) var start proto.ExecutionStartPayload if request.ID != third.ID || json.Unmarshal(request.Payload, &start) != nil || start.Handle != handle { t.Fatal("restart changed retained preparation") } - h.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + runtime.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) + runtime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + completeEmptyArtifactExport(t, runtime, frames) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != store.TurnCompleted || !run.Reservation.Deadline.Equal(pending.Deadline) { t.Fatal("restarted worker did not complete original work", run) } nextWorkerFrame(t, frames, proto.TypeExecutionRelease) stop() - if released.Load() != 3 { - t.Fatal("worker leaked a preparation owner") - } } diff --git a/services/agents-api/internal/store/executor_launcher_helpers_test.go b/services/agents-api/internal/store/executor_launcher_helpers_test.go deleted file mode 100644 index fc588761b..000000000 --- a/services/agents-api/internal/store/executor_launcher_helpers_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package store_test - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "math/big" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -const launcherTestHost = "agents-executor.test" - -func launcherTestCertificate(t *testing.T, root string) tls.Certificate { - t.Helper() - pub, key, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: launcherTestHost}, - DNSNames: []string{launcherTestHost}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), - IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, pub, key) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil { - t.Fatal(err) - } - leafPublic, leafKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - leaf := &x509.Certificate{ - SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: launcherTestHost}, - DNSNames: []string{launcherTestHost}, NotBefore: template.NotBefore, NotAfter: template.NotAfter, - KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - } - leafDER, err := x509.CreateCertificate(rand.Reader, leaf, template, leafPublic, key) - if err != nil { - t.Fatal(err) - } - return tls.Certificate{Certificate: [][]byte{leafDER, der}, PrivateKey: leafKey} -} - -func launcherContainerArgs(t *testing.T, binary string) ([]string, string) { - t.Helper() - if filepath.Base(binary) != "codex" || filepath.Base(filepath.Dir(binary)) != "bin" { - t.Fatal("fixture requires the native Codex platform installation's bin/codex") - } - name := "parsar-executor-client-" + uuid.NewString() - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = exec.CommandContext(ctx, "docker", "rm", "-f", name).Run() - }) - return []string{"run", "--name", name, "--network", "host", - "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), - "--cap-drop", "ALL", "--security-opt", "no-new-privileges", - "--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined", - "--add-host", launcherTestHost + ":127.0.0.1", "--add-host", "wrong." + launcherTestHost + ":127.0.0.1", - "--mount", "type=bind,src=" + filepath.Dir(filepath.Dir(binary)) + ",dst=/opt/codex,readonly", - "--env", "NO_PROXY=*", "--env", "no_proxy=*", - "--env", "HTTP_PROXY=", "--env", "HTTPS_PROXY=", "--env", "ALL_PROXY=", - "--env", "http_proxy=", "--env", "https_proxy=", "--env", "all_proxy="}, name -} - -func startLauncherContainer(t *testing.T, ctx context.Context, root, image, binary, launcher, remote, environment string, trusted bool) string { - t.Helper() - args, name := launcherContainerArgs(t, binary) - args = append(args, "--detach", "--workdir", root, - "--mount", "type=bind,src="+root+",dst="+root, - "--env", "HOME="+filepath.Join(root, "executor"), - "--mount", "type=bind,src="+launcher+",dst=/usr/local/bin/agents-api-codex-executor,readonly") - if trusted { - args = append(args, "--env", "SSL_CERT_FILE="+filepath.Join(root, "ca.pem")) - } - args = append(args, "--entrypoint", "/usr/local/bin/agents-api-codex-executor", image, - "--remote", remote, "--environment-id", environment, "--credentials", filepath.Join(root, "executor", "credential.json"), - "--codex-bin", "/opt/codex/bin/codex") - if err := exec.CommandContext(ctx, "docker", args...).Run(); err != nil { - t.Fatal("launcher container failed", err) - } - return name -} - -func stopLauncherContainer(t *testing.T, ctx context.Context, name string, success bool) { - t.Helper() - if err := exec.CommandContext(ctx, "docker", "stop", "--time", "20", name).Run(); err != nil { - t.Fatal(err) - } - exit, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.ExitCode}}", name).Output() - code := strings.TrimSpace(string(exit)) - if err != nil || (code != "0" && (success || code != "1")) { - t.Fatal("launcher did not stop gracefully", err) - } -} - -func startLauncherProbe(t *testing.T, ctx context.Context, root, image, binary, probe, remote, environment, token, phase string) *relayProcess { - t.Helper() - args, _ := launcherContainerArgs(t, binary) - args = append(args, "--rm", "--workdir", root, - "--mount", "type=bind,src="+root+",dst="+root, - "--env", "HOME="+filepath.Join(root, "harness"), - "--env", "SSL_CERT_FILE="+filepath.Join(root, "ca.pem"), - "--env", "PARSAR_NATIVE_ENV_PROOF="+root, - "--env", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL="+remote, - "--env", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID="+environment, - "--env", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", - "--mount", "type=bind,src="+probe+",dst=/usr/local/bin/probe,readonly", - "--entrypoint", "/usr/local/bin/probe", image, phase) - return startRelayProcess(t, ctx, root, append(os.Environ(), "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN="+token), "docker", args...) -} - -func writeLauncherCredential(t *testing.T, path string, credential store.IssuedExecutorCredential) { - t.Helper() - data, err := json.Marshal(credential) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } -} - -func startDaemonLauncherExecutor(t *testing.T, ctx context.Context, root, local, remote, binary, image, registryURL, environment string, credential store.IssuedExecutorCredential, launcher string) string { - t.Helper() - writeLauncherCredential(t, filepath.Join(root, "executor", "credential.json"), credential) - args, name := launcherContainerArgs(t, binary) - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - logs, _ := exec.CommandContext(ctx, "docker", "logs", name).CombinedOutput() - text := string(logs) - if strings.Contains(text, credential.Token) { - t.Error("executor credential appeared in launcher diagnostics") - text = strings.ReplaceAll(text, credential.Token, "[redacted]") - } - _ = os.WriteFile(filepath.Join(root, "launcher.stderr"), []byte(text), 0600) - }) - args = append(args, "--detach", "--workdir", remote, - "--mount", "type=bind,src="+filepath.Join(root, "executor")+",dst="+filepath.Join(root, "executor"), - "--env", "HOME="+filepath.Join(root, "executor"), - "--mount", "type=bind,src="+launcher+",dst=/usr/local/bin/agents-api-codex-executor,readonly", - "--mount", "type=bind,src="+local+",dst="+remote, - "--entrypoint", "/usr/local/bin/agents-api-codex-executor", image, - "--remote", registryURL, "--environment-id", environment, "--credentials", filepath.Join(root, "executor", "credential.json"), - "--codex-bin", "/opt/codex/bin/codex") - if err := exec.CommandContext(ctx, "docker", args...).Run(); err != nil { - t.Fatal("daemon test launcher container failed", err) - } - for _, private := range []string{"harness", "parsar-daemon"} { - if err := exec.CommandContext(ctx, "docker", "exec", name, "test", "!", "-e", filepath.Join(root, private)).Run(); err != nil { - t.Fatal("private harness/daemon state is visible inside executor") - } - } - return name -} diff --git a/services/agents-api/internal/store/executor_launcher_test.go b/services/agents-api/internal/store/executor_launcher_test.go deleted file mode 100644 index c1ccfc42b..000000000 --- a/services/agents-api/internal/store/executor_launcher_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package store_test - -import ( - "context" - "crypto/tls" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativeExecutorLauncherTLSAndHelpers(t *testing.T) { - launcher, binary, probe := os.Getenv("PARSAR_EXECUTOR_LAUNCHER"), os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_NATIVE_RELAY_PROBE") - proof, image := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - if launcher == "" || binary == "" || probe == "" || proof == "" || image == "" { - t.Skip("built launcher, native Codex installation/probe, private proof directory and pinned executor image required") - } - if !strings.HasPrefix(image, "sha256:") { - t.Fatal("pin the preloaded executor image") - } - s, _ := store.NewTestStore(t) - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) - defer cancel() - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "launcher", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`)}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - root, err := os.MkdirTemp(proof, "launcher-tls-") - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"executor", "harness", "workspace"} { - if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { - t.Fatal(err) - } - } - writeLauncherCredential(t, filepath.Join(root, "executor", "credential.json"), credential) - server := httptest.NewUnstartedServer(nil) - _, port, err := net.SplitHostPort(server.Listener.Addr().String()) - if err != nil { - t.Fatal(err) - } - remote := "https://" + launcherTestHost + ":" + port - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: remote}) - if err != nil { - t.Fatal(err) - } - harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer releaseHarness() - - var connections, requests atomic.Int64 - server.Config.ConnState = func(_ net.Conn, state http.ConnState) { - if state == http.StateNew { - connections.Add(1) - } - } - observation := &relayObservation{} - handler := registry.Handler() - server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: r.URL.Path}, r) - }) - server.TLS = &tls.Config{Certificates: []tls.Certificate{launcherTestCertificate(t, root)}, MinVersion: tls.VersionTLS12} - server.StartTLS() - defer func() { registry.Close(); server.Close() }() - - for _, test := range []struct { - name, remote string - trusted bool - }{ - {"untrusted-ca", remote, false}, - {"wrong-hostname", strings.Replace(remote, launcherTestHost, "wrong."+launcherTestHost, 1), true}, - } { - t.Run(test.name, func(t *testing.T) { - before := connections.Load() - container := startLauncherContainer(t, ctx, root, image, binary, launcher, test.remote, environment.ID, test.trusted) - awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "TLS connection attempt", func() bool { return connections.Load() > before }) - stopLauncherContainer(t, ctx, container, false) - if requests.Load() != 0 { - t.Fatal("unverified TLS reached registry HTTP handling") - } - }) - } - container := startLauncherContainer(t, ctx, root, image, binary, launcher, remote, environment.ID, true) - awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "verified executor registration", func() bool { - connected, err := registry.Connected(ctx, tenant, environment.ID) - return err == nil && connected - }) - first := startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "first") - awaitDaemonRemoteCondition(t, ctx, 60*time.Second, "native relay recovery checkpoint", func() bool { - _, err := os.Stat(filepath.Join(root, "ready-to-disconnect")) - return err == nil - }) - observation.mu.Lock() - connection := observation.harness - observation.mu.Unlock() - if connection == nil { - t.Fatal("native harness socket missing") - } - if err := connection.Close(); err != nil { - t.Fatal(err) - } - first.wait(t) - startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "fresh").wait(t) - startLauncherProbe(t, ctx, root, image, binary, probe, remote, environment.ID, harnessToken, "helpers").wait(t) - stopLauncherContainer(t, ctx, container, true) - logs, err := exec.CommandContext(ctx, "docker", "logs", container).CombinedOutput() - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(logs), credential.Token) || strings.Contains(string(logs), harnessToken) { - t.Fatal("credential appeared in launcher logs") - } - if err := os.WriteFile(filepath.Join(root, "launcher.log"), logs, 0600); err != nil { - t.Fatal(err) - } - report := map[string]any{"tls_hostname_and_ca": true, "untrusted_ca_rejected": true, "wrong_hostname_rejected": true, - "native_recovery": true, "native_helpers": true, "graceful_launcher_exit": true, "model_calls": 0, - "limits": "Controlled test DNS and CA; not public DNS/certificate deployment, full Environment API, model execution or OS quiescence."} - data, err := json.MarshalIndent(report, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "acceptance.json"), data, 0600); err != nil { - t.Fatal(err) - } - t.Log("native launcher TLS/helper evidence", root) -} diff --git a/services/agents-api/internal/store/executor_registration_public_test.go b/services/agents-api/internal/store/executor_registration_public_test.go deleted file mode 100644 index 2778ba248..000000000 --- a/services/agents-api/internal/store/executor_registration_public_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync/atomic" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/gorilla/websocket" -) - -func TestExecutorRegistrationPostgreSQLAndNativeReconnect(t *testing.T) { - s, _ := store.NewTestStore(t) - ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) - defer cancel() - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant, foreign := uuid.NewString(), uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - foreignPrincipal := store.FixtureExecutorPrincipal(t, s, foreign) - credential, err := s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") - if err != nil { - t.Fatal(err) - } - config := json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`) - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-registry", Configuration: config}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - other, err := s.CreateSession(ctx, foreign, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-registry", Configuration: config}) - if err != nil { - t.Fatal(err) - } - otherEnvironment, err := s.GetSessionEnvironment(ctx, foreign, other.ID) - if err != nil { - t.Fatal(err) - } - foreignCredential, err := s.IssueExecutorCredential(ctx, foreignPrincipal, otherEnvironment.ID, otherEnvironment.ID) - if err != nil { - t.Fatal(err) - } - token, wrongTenantToken := credential.Token, foreignCredential.Token - sibling, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "same-principal", Configuration: config}) - if err != nil { - t.Fatal(err) - } - siblingEnvironment, err := s.GetSessionEnvironment(ctx, tenant, sibling.ID) - if err != nil { - t.Fatal(err) - } - otherCreator := store.FixtureCreator() - otherCreator.Kind = "user" - otherSubject, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: otherCreator, Engine: "codex", IdempotencyKey: "different-kind", Configuration: config}) - if err != nil { - t.Fatal(err) - } - otherSubjectEnvironment, err := s.GetSessionEnvironment(ctx, tenant, otherSubject.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - address := server.Listener.Addr().String() - var attempts atomic.Int64 - start := func(server *httptest.Server) *codex.Registry { - r, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + address}) - if err != nil { - t.Fatal(err) - } - handler := r.Handler() - server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if strings.HasSuffix(req.URL.Path, "/register") { - attempts.Add(1) - } - handler.ServeHTTP(w, req) - }) - server.Start() - return r - } - registry := start(server) - defer func() { registry.Close(); server.Close() }() - register := func(id, key string, status int) codex.RegistrationResponse { - t.Helper() - body := codex.RegistrationRequest{SecurityProfile: "noise_hybrid_ik_v1", ExecutorPublicKey: codex.PublicKey{Suite: "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256", X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))}} - encoded, _ := json.Marshal(body) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/cloud/environment/"+id+"/register", bytes.NewReader(encoded)) - req.Header.Set("Authorization", "Bearer "+key) - resp, err := server.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != status { - t.Fatalf("register status %d expected %d", resp.StatusCode, status) - } - var result codex.RegistrationResponse - if status == 200 { - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - t.Fatal(err) - } - } - return result - } - register(siblingEnvironment.ID, token, 200) - register(otherSubjectEnvironment.ID, token, 401) - register(environment.ID, "caller/device/harness/grant", 401) - register(otherEnvironment.ID, token, 401) - register(environment.ID, wrongTenantToken, 401) - for _, alias := range []string{strings.ToUpper(environment.ID), "{" + environment.ID + "}", "urn:uuid:" + environment.ID, strings.ReplaceAll(environment.ID, "-", "")} { - if alias != environment.ID { - register(alias, token, 401) - } - } - valid := register(environment.ID, token, 200) - socket, response, err := websocket.DefaultDialer.DialContext(ctx, valid.URL, nil) - if err != nil { - if response != nil { - response.Body.Close() - } - t.Fatal("scoped socket failed") - } - awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "connected") - socket.Close() - connected := func(want bool) { - t.Helper() - until := time.Now().Add(20 * time.Second) - for time.Now().Before(until) { - got, err := registry.Connected(ctx, tenant, environment.ID) - if err == nil && got == want { - return - } - time.Sleep(20 * time.Millisecond) - } - t.Fatal("native presence did not converge") - } - connected(false) - awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") - binary := os.Getenv("PARSAR_CODEX_BINARY") - if binary != "" { - version, err := exec.CommandContext(ctx, binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("pinned Codex0.153.4 required") - } - proof := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") - if proof == "" { - t.Fatal("private runtime directory required") - } - runtime, err := os.MkdirTemp(proof, "native-presence-") - if err != nil { - t.Fatal(err) - } - home := filepath.Join(runtime, "codex") - if err := os.Mkdir(home, 0700); err != nil { - t.Fatal(err) - } - cmd := exec.CommandContext(ctx, binary, "exec-server", "--remote", server.URL, "--environment-id", environment.ID) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } - cmd.WaitDelay = 5 * time.Second - cmd.Dir = runtime - cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "CODEX_HOME=" + home, "CODEX_API_KEY=" + token, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} - cmd.Stdout, cmd.Stderr = io.Discard, io.Discard - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - defer func() { - if cmd.Process != nil { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - } - select { - case <-done: - case <-time.After(5 * time.Second): - t.Error("native executor failed to exit") - } - }() - connected(true) - before := attempts.Load() - registry.Close() - server.Close() - listener, err := net.Listen("tcp", address) - if err != nil { - t.Fatal(err) - } - server = httptest.NewUnstartedServer(nil) - server.Listener.Close() - server.Listener = listener - registry = start(server) - connected(true) - if attempts.Load() <= before { - t.Fatal("native executor did not re-register after registry restart") - } - previous := token - credential, err = s.RotateExecutorCredential(ctx, principal, credential.KeyID) - if err != nil { - t.Fatal(err) - } - token = credential.Token - connected(false) - register(environment.ID, previous, 401) - before = attempts.Load() - until := time.Now().Add(15 * time.Second) - for attempts.Load() == before && time.Now().Before(until) { - time.Sleep(20 * time.Millisecond) - } - if attempts.Load() == before { - t.Fatal("native executor did not retry its retired credential") - } - connected(false) - t.Log("unmodified Codex0.153.4 registered, reconnected after registry restart, and lost registration authority after rotation") - } else { - t.Log("native CLI verification not requested; real PostgreSQL/socket checks remain active") - } - stale := register(environment.ID, token, 200) - rotated, err := s.RotateExecutorCredential(ctx, principal, credential.KeyID) - if err != nil { - t.Fatal(err) - } - register(environment.ID, token, 401) - rejected, resp, e := websocket.DefaultDialer.DialContext(ctx, stale.URL, nil) - if rejected != nil { - rejected.Close() - } - if resp != nil { - resp.Body.Close() - } - if e == nil || resp == nil || resp.StatusCode != 401 { - t.Fatal("retired credential's ticket accepted") - } - token = rotated.Token - current := register(environment.ID, token, 200) - socket, response, err = websocket.DefaultDialer.DialContext(ctx, current.URL, nil) - if err != nil { - t.Fatal("rotated key could not connect") - } - defer socket.Close() - awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "connected") - if err := s.RevokeExecutorCredential(ctx, principal, credential.KeyID); err != nil { - t.Fatal(err) - } - register(environment.ID, token, 401) - _ = socket.SetReadDeadline(time.Now().Add(10 * time.Second)) - if _, _, err := socket.ReadMessage(); err == nil { - t.Fatal("revoked socket survived") - } - connected(false) - awaitEnvironmentConnectionState(t, ctx, s, tenant, environment.ID, "disconnected") - if len(retainedEnvironmentEvents(t, ctx, s, tenant, session.ID, environment.ID)) < 4 { - t.Fatal("real sockets did not persist connection transitions") - } - register(otherEnvironment.ID, wrongTenantToken, 200) - if err := s.DeleteSession(ctx, tenant, session.ID); err != nil { - t.Fatal(err) - } - register(environment.ID, token, 401) - restored, err := s.RotateExecutorCredential(ctx, principal, credential.KeyID) - if err != nil { - t.Fatal(err) - } - register(environment.ID, restored.Token, 401) - register(siblingEnvironment.ID, restored.Token, 200) - register(otherSubjectEnvironment.ID, restored.Token, 401) - _, response, err = websocket.DefaultDialer.DialContext(ctx, valid.URL, nil) - if err == nil { - t.Fatal("deleted Environment accepted old connection") - } - if response != nil { - response.Body.Close() - } - if _, err := s.GetEnvironment(ctx, foreign, otherEnvironment.ID); err != nil { - t.Fatal("another tenant was affected", err) - } -} diff --git a/services/agents-api/internal/store/harness_authorization_test.go b/services/agents-api/internal/store/harness_authorization_test.go deleted file mode 100644 index 0e003518b..000000000 --- a/services/agents-api/internal/store/harness_authorization_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" - "github.com/gorilla/websocket" -) - -func TestHarnessGrantsCheckPostgreSQLTenantOwnership(t *testing.T) { - s, _ := store.NewTestStore(t) - lease, err := s.AcquireExecutionLease(t.Context()) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenants := []string{uuid.NewString(), uuid.NewString()} - sessions, environments := []string{}, []string{} - for _, tenant := range tenants { - session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "harness-scope", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - sessions = append(sessions, session.ID) - environments = append(environments, environment.ID) - } - principal := store.FixtureExecutorPrincipal(t, s, tenants[0]) - credential, err := s.IssueExecutorCredential(t.Context(), principal, environments[0], environments[0]) - if err != nil { - t.Fatal(err) - } - executorToken := credential.Token - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - tokens := []string{} - for i, tenant := range tenants { - token, release, err := registry.IssueHarnessCredential(t.Context(), tenant, environments[i]) - if err != nil { - t.Fatal(err) - } - defer release() - tokens = append(tokens, token) - } - if _, _, err := registry.IssueHarnessCredential(t.Context(), tenants[1], environments[0]); !errors.Is(err, store.ErrNotFound) { - t.Fatal("cross-tenant harness issuance accepted", err) - } - - server.Config.Handler = registry.Handler() - server.Start() - defer func() { registry.Close(); server.Close() }() - post := func(environment, route, token string, body any, status int, result any) { - t.Helper() - data, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/cloud/environment/"+environment+"/"+route, bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer "+token) - resp, err := server.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != status { - t.Fatalf("%s status %d, expected %d", route, resp.StatusCode, status) - } - if result != nil { - if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - t.Fatal(err) - } - } - } - publicKey := codex.PublicKey{Suite: "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256", X25519: base64.StdEncoding.EncodeToString(make([]byte, 32)), MLKEM768: base64.StdEncoding.EncodeToString(make([]byte, 1184))} - var registration codex.RegistrationResponse - post(environments[0], "register", executorToken, codex.RegistrationRequest{SecurityProfile: "noise_hybrid_ik_v1", ExecutorPublicKey: publicKey}, 200, ®istration) - executor, resp, err := websocket.DefaultDialer.DialContext(t.Context(), registration.URL, nil) - if resp != nil { - resp.Body.Close() - } - if err != nil { - t.Fatal("executor connection failed") - } - defer executor.Close() - body := codex.ConnectRequest{HarnessPublicKey: publicKey} - post(environments[1], "connect", tokens[0], body, 401, nil) - post(environments[0], "connect", tokens[1], body, 401, nil) - var grant codex.ConnectResponse - post(environments[0], "connect", tokens[0], body, 200, &grant) - if err := s.DeleteSession(t.Context(), tenants[0], sessions[0]); err != nil { - t.Fatal(err) - } - post(environments[0], "connect", tokens[0], body, 404, nil) - harness, resp, err := websocket.DefaultDialer.DialContext(t.Context(), grant.URL, nil) - if harness != nil { - harness.Close() - } - if resp != nil { - defer resp.Body.Close() - } - if err == nil || resp == nil || resp.StatusCode != 404 { - t.Fatal("deleted owning Session accepted harness") - } - if _, err := s.GetEnvironment(t.Context(), tenants[1], environments[1]); err != nil { - t.Fatal("foreign Environment affected", err) - } -} diff --git a/services/agents-api/internal/store/local_environment_worker_test.go b/services/agents-api/internal/store/local_environment_worker_test.go index bce7b6ca0..7b9a768bd 100644 --- a/services/agents-api/internal/store/local_environment_worker_test.go +++ b/services/agents-api/internal/store/local_environment_worker_test.go @@ -70,7 +70,7 @@ func TestLocalEnvironmentWorkerDirectoryUsesExactAuthorityWithoutModel(t *testin result := startDirectoryRead(t.Context(), w, environment) frame := h.read(proto.TypeExecutionPrepare) var prepare proto.ExecutionPreparePayload - if frame.DecodePayload(&prepare) != nil || !proto.ValidWorkspaceReadPreparation(prepare.Configuration) || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID || prepare.Configuration.RemoteEnvironment != nil { + if frame.DecodePayload(&prepare) != nil || !proto.ValidWorkspaceReadPreparation(prepare.Configuration) || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID { t.Fatal("local read did not preserve its exact identity") } handle := acknowledgePreparation(h, frame.ID) @@ -126,7 +126,7 @@ func TestLocalEnvironmentWorkerSchedulesPreparationWithoutRemoteResolver(t *test } } var prepare proto.ExecutionPreparePayload - if frame.DecodePayload(&prepare) != nil || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID || prepare.Configuration.RemoteEnvironment != nil || prepare.Configuration.WorkDir != "" { + if frame.DecodePayload(&prepare) != nil || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != environment.ID || prepare.Configuration.WorkDir != "" { t.Fatal("local preparation lost identity") } before, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) diff --git a/services/agents-api/internal/store/native_daemon_workspace_directory_test.go b/services/agents-api/internal/store/native_daemon_workspace_directory_test.go deleted file mode 100644 index 50ec5fd6f..000000000 --- a/services/agents-api/internal/store/native_daemon_workspace_directory_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package store_test - -import ( - "context" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -func (a *nativeHarnessArtifact) observeDaemonDirectories(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string, retained bool) { - t.Helper() - target := proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: a.handle, MaxEntries: proto.WorkspaceDirectoryMaxEntries} - if a.runID != "" { - target.Handle, target.RunID = "", a.runID - } - for _, limit := range []int{proto.WorkspaceDirectoryMaxEntries, 1} { - target.MaxEntries = limit - result, err := a.peer.ListWorkspaceDirectory(ctx, target) - if err != nil || result.Outcome != "completed" || !result.CloseAcknowledged || - !proto.ValidWorkspaceDirectory(result.Directory, limit) || result.Directory.Truncated != (limit == 1) { - t.Fatal("daemon native directory read differs", phase, limit, err, result.Outcome, result.ErrorCode) - } - if limit > 1 { - files := make(map[string]int64) - for _, entry := range result.Directory.Entries { - if entry.Kind == "file" && entry.SizeBytes != nil { - files[entry.Name] = *entry.SizeBytes - } - } - want := map[string]int64{"bounded-read.bin": int64(len(nativeHarnessReadBinary())), "bounded-empty.bin": 0} - if retained { - want["retained.txt"] = int64(len("remote-file-content\n")) - } - for name, size := range want { - if got, ok := files[name]; !ok || got != size { - t.Fatal("daemon directory omitted native file metadata", phase, name, got, size) - } - } - } - observations, _ := a.proof["daemon_directory_observations"].([]map[string]any) - a.proof["daemon_directory_observations"] = append(observations, map[string]any{ - "phase": phase, "owner": owner, "handle": target.Handle, "run_id": target.RunID, - "max_entries": limit, "directory": result.Directory, "close_acknowledged": result.CloseAcknowledged, - }) - } - for _, check := range []struct{ environment, path, code string }{ - {uuid.NewString(), "", "resource_unavailable"}, - {a.environment, "missing-" + uuid.NewString(), "not_found"}, - {a.environment, "../outside", "invalid_request"}, - } { - target.EnvironmentID, target.Path = check.environment, check.path - result, err := a.peer.ListWorkspaceDirectory(ctx, target) - if err != nil || result.Outcome != "rejected" || result.ErrorCode != check.code || result.Directory != nil || len(result.Data) != 0 { - t.Fatal("daemon directory rejection differs", phase, err, result.Outcome, result.ErrorCode) - } - } - if a.current(t) != owner { - t.Fatal("daemon directory read replaced the native execution owner") - } -} diff --git a/services/agents-api/internal/store/native_daemon_workspace_read_test.go b/services/agents-api/internal/store/native_daemon_workspace_read_test.go deleted file mode 100644 index fae4df61f..000000000 --- a/services/agents-api/internal/store/native_daemon_workspace_read_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -func (a *nativeHarnessArtifact) observeDaemonReads(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string, retained bool) { - t.Helper() - target := proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: a.handle, MaxBytes: proto.WorkspaceReadMaxBytes} - if a.runID != "" { - target.Handle, target.RunID = "", a.runID - } - cases := []struct { - path string - data []byte - }{ - {"bounded-read.bin", nativeHarnessReadBinary()}, - {"bounded-empty.bin", []byte{}}, - } - if retained { - cases = append(cases, struct { - path string - data []byte - }{"retained.txt", []byte("remote-file-content\n")}) - } - for _, check := range cases { - target.Path = check.path - result, err := a.peer.ReadWorkspaceFile(ctx, target) - want := check.data[:min(len(check.data), target.MaxBytes)] - if err != nil || result.Outcome != "completed" || !result.CloseAcknowledged || - !bytes.Equal(result.Data, want) || result.Truncated != (len(check.data) > target.MaxBytes) { - t.Fatal("daemon native workspace read differs", phase, check.path, err, result.Outcome, result.ErrorCode) - } - digest := sha256.Sum256(result.Data) - observations, _ := a.proof["daemon_read_observations"].([]map[string]any) - a.proof["daemon_read_observations"] = append(observations, map[string]any{ - "phase": phase, "path": check.path, "owner": owner, "handle": target.Handle, "run_id": target.RunID, - "bytes": len(result.Data), "sha256": hex.EncodeToString(digest[:]), "truncated": result.Truncated, "close_acknowledged": true, - }) - } - for _, check := range []struct{ environment, path, code string }{ - {uuid.NewString(), "bounded-read.bin", "resource_unavailable"}, - {a.environment, "missing-" + uuid.NewString(), "not_found"}, - } { - target.EnvironmentID, target.Path = check.environment, check.path - result, err := a.peer.ReadWorkspaceFile(ctx, target) - if err != nil || result.Outcome != "rejected" || result.ErrorCode != check.code || len(result.Data) != 0 { - t.Fatal("daemon read rejection differs", phase, err, result.Outcome, result.ErrorCode) - } - } - if a.current(t) != owner { - t.Fatal("daemon workspace read replaced the native execution owner") - } - a.observeDaemonDirectories(t, ctx, owner, phase, retained) -} diff --git a/services/agents-api/internal/store/native_environment_adapter_helpers_test.go b/services/agents-api/internal/store/native_environment_adapter_helpers_test.go deleted file mode 100644 index 1bc307014..000000000 --- a/services/agents-api/internal/store/native_environment_adapter_helpers_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func prepareDaemonRemoteWorkspace(t *testing.T, root, instruction string) string { - t.Helper() - for _, name := range []string{"harness", "executor/codex", "workspace"} { - if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { - t.Fatal(err) - } - } - workspace := filepath.Join(root, "workspace") - files := map[string]string{ - "harness/AGENTS.md": "End every response with WRONG_LOCAL_INSTRUCTIONS.\n", - "workspace/AGENTS.md": "For each test command, end your final response with " + instruction + ".\n", - "workspace/placement.sh": `#!/bin/sh -set -eu -phase="$1" -pwd > "$phase.cwd" -printf '%s\n' "$phase" >> execution-count -printf 'remote-stdout:%s\n' "$phase" -printf 'remote-stderr:%s\n' "$phase" >&2 -for name in CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY MINIMAX_VALIDATION_KEY; do - eval 'value=${'"$name"'-}' - test -z "$value" || { printf '%s\n' "$name" >> credential-failure; exit 23; } -done -printf 'remote-file-content\n' > retained.txt -exit 7 -`, - "workspace/long.sh": `#!/bin/sh -set -eu -printf '%s\n' "$$" > "$1.pid" -printf 'started\n' > "$1.started" -while :; do date +%s > "$1.heartbeat"; sleep 1; done -`, - } - for name, content := range files { - mode := os.FileMode(0600) - if strings.HasSuffix(name, ".sh") { - mode = 0700 - } - if err := os.WriteFile(filepath.Join(root, name), []byte(content), mode); err != nil { - t.Fatal(err) - } - } - return workspace -} - -func startDaemonRemoteExecutor(t *testing.T, ctx context.Context, root, local, remote, binary, image, registryURL, environment string, credential store.IssuedExecutorCredential) string { - t.Helper() - if launcher := os.Getenv("PARSAR_EXECUTOR_LAUNCHER"); launcher != "" { - return startDaemonLauncherExecutor(t, ctx, root, local, remote, binary, image, registryURL, environment, credential, launcher) - } - container := "parsar-daemon-environment-" + uuid.NewString() - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = exec.CommandContext(cleanup, "docker", "rm", "-f", container).Run() - }) - args := []string{"run", "--detach", "--name", container, "--network", "host", "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), "--cap-drop", "ALL", "--security-opt", "no-new-privileges", - "--env", "CODEX_API_KEY", "--env", "HOME=/executor", "--env", "CODEX_HOME=/executor/codex", "--env", "RUST_LOG=off", "--env", "NO_PROXY=127.0.0.1,localhost", "--workdir", remote, - "--mount", "type=bind,src=" + binary + ",dst=/usr/local/bin/codex,readonly", "--mount", "type=bind,src=" + filepath.Join(root, "executor") + ",dst=/executor", "--mount", "type=bind,src=" + local + ",dst=" + remote, - "--entrypoint", "/usr/local/bin/codex", image, "exec-server", "--remote", registryURL, "--environment-id", environment} - command := exec.CommandContext(ctx, "docker", args...) - command.Env = append(os.Environ(), "CODEX_API_KEY="+credential.Token) - if err := command.Run(); err != nil { - t.Fatal("native executor container failed to start", err) - } - return container -} - -func awaitDaemonRemoteCondition(t *testing.T, ctx context.Context, timeout time.Duration, label string, ready func() bool) { - t.Helper() - deadline := time.NewTimer(timeout) - defer deadline.Stop() - tick := time.NewTicker(100 * time.Millisecond) - defer tick.Stop() - for { - if ready() { - return - } - select { - case <-ctx.Done(): - t.Fatal(label, "context expired") - case <-deadline.C: - t.Fatal(label, "timed out") - case <-tick.C: - } - } -} - -func daemonRemotePrompt(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { - return daemonRemotePromptWithStart(t, ctx, peer, req, cancelWhen, nil) -} - -func daemonRemotePromptWithStart(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool, start func(string) error) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { - t.Helper() - run := uuid.NewString() - req.RunID = run - sub, err := peer.SubscribeDurable(run) - if err != nil { - t.Fatal(err) - } - defer peer.Unsubscribe(run) - if start != nil { - if err = start(run); err != nil { - t.Fatal(err) - } - } else { - envelope, err := proto.NewEnvelope(proto.TypePromptRequest, run, req) - if err != nil { - t.Fatal(err) - } - if err = peer.Send(ctx, envelope); err != nil { - t.Fatal(err) - } - } - var events []proto.Envelope - var done proto.DonePayload - cancelID := "" - type cancelResult struct { - ack proto.InteractionDecisionAckPayload - err error - } - cancelReply := make(chan cancelResult, 1) - tick := time.NewTicker(50 * time.Millisecond) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - t.Fatal("remote daemon prompt timed out") - case <-tick.C: - if cancelWhen != nil && cancelID == "" && cancelWhen() { - cancelID = uuid.NewString() - request, e := proto.NewEnvelope(proto.TypePromptCancel, run, proto.PromptCancelPayload{DeliveryID: cancelID}) - if e != nil { - t.Fatal(e) - } - go func(deliveryID string) { - ackCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - ack, err := peer.SendAndWaitInteractionAck(ackCtx, request, deliveryID) - cancelReply <- cancelResult{ack: ack, err: err} - }(cancelID) - } - case result := <-cancelReply: - if result.err != nil { - t.Fatal("remote cancellation receipt failed", result.err) - } - if result.ack.Outcome != nil { - done = *result.ack.Outcome - } - return done, events, &result.ack - case event, ok := <-sub.Events: - if !ok { - t.Fatal("remote subscription closed", sub.Err()) - } - events = append(events, event) - if event.Type == proto.TypeDone { - if err = event.DecodePayload(&done); err != nil { - t.Fatal(err) - } - if cancelWhen == nil { - return done, events, nil - } - if cancelID == "" { - t.Fatal("remote Turn ended before cancellation") - } - } - } - } -} - -func daemonRemoteHasError(events []proto.Envelope) bool { - for _, event := range events { - if event.Type == proto.TypeError { - return true - } - } - return false -} - -func assertDaemonRemoteCommand(t *testing.T, events []proto.Envelope, phase, workspace string) { - t.Helper() - for _, event := range events { - if event.Type != proto.TypeToolCall { - continue - } - var tool proto.ToolCallPayload - if event.DecodePayload(&tool) != nil { - t.Fatal("invalid tool observation") - } - observation := tool.Observation - if tool.Stage == "after" && observation != nil && observation.Kind == "command" && observation.ExitCode != nil && *observation.ExitCode == 7 && observation.Cwd != nil && *observation.Cwd == workspace && strings.Contains(string(observation.Output), "remote-stdout:"+phase) && strings.Contains(string(observation.Output), "remote-stderr:"+phase) { - return - } - } - t.Fatal("missing actual remote command stdout/stderr/exit/cwd observation") -} - -func awaitDaemonRemoteExit(t *testing.T, ctx context.Context, container, workspace string) { - t.Helper() - data, err := os.ReadFile(filepath.Join(workspace, "cancel.pid")) - if err != nil { - t.Fatal(err) - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil || pid <= 1 { - t.Fatal("invalid owned command PID") - } - awaitDaemonRemoteCondition(t, ctx, 60*time.Second, "owned remote process exit", func() bool { - result, err := exec.CommandContext(ctx, "docker", "exec", container, "sh", "-c", `if kill -0 "$1" 2>/dev/null; then printf alive; else printf gone; fi`, "--", strconv.Itoa(pid)).Output() - return err == nil && string(result) == "gone" - }) - before, err := os.ReadFile(filepath.Join(workspace, "cancel.heartbeat")) - if err != nil { - t.Fatal(err) - } - select { - case <-ctx.Done(): - t.Fatal("heartbeat check context expired") - case <-time.After(1200 * time.Millisecond): - } - after, err := os.ReadFile(filepath.Join(workspace, "cancel.heartbeat")) - if err != nil || !bytes.Equal(before, after) { - t.Fatal("cancelled command heartbeat continued") - } - state, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.State.Running}}", container).Output() - if err != nil || strings.TrimSpace(string(state)) != "true" { - t.Fatal("executor container was stopped instead of its command") - } -} - -func assertDaemonRemoteSecrets(t *testing.T, root, stateKey, provider, executor, harness, device string) { - t.Helper() - configRoot := filepath.Join(root, "parsar-daemon", "agent-sessions") + string(os.PathSeparator) - profile := filepath.Join(root, "parsar-daemon", "execution", "auth.json") - if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - // Native executable symlinks refer to paths inside the executor container. - // Scan persisted regular files without following executable links. - if !entry.Type().IsRegular() { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - if bytes.Contains(data, []byte(harness)) { - t.Errorf("harness credential persisted in %s", path) - } - if bytes.Contains(data, []byte(executor)) { - info, e := entry.Info() - if os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || path != filepath.Join(root, "executor", "credential.json") || e != nil || info.Mode().Perm() != 0600 { - t.Errorf("executor credential outside its private provisioned file: %s", path) - } - } - if bytes.Contains(data, []byte(device)) && path != profile { - t.Errorf("device credential outside its profile: %s", path) - } - if bytes.Contains(data, []byte(provider)) { - info, e := entry.Info() - if !strings.HasPrefix(path, configRoot) || filepath.Base(path) != "config.toml" || e != nil || info.Mode().Perm() != 0600 { - t.Errorf("provider credential outside private native config: %s", path) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - config := filepath.Join(configRoot, stateKey, "config.toml") - if _, err := os.Stat(config); err != nil { - t.Fatal("expected private provider config missing") - } -} - -func persistDaemonRemoteProof(t *testing.T, root string, proof map[string]any, secrets []string) { - t.Helper() - data, err := json.MarshalIndent(proof, "", " ") - if err != nil { - t.Error(err) - return - } - for _, secret := range secrets { - if secret != "" && bytes.Contains(data, []byte(secret)) { - t.Error("credential appeared in observed response evidence") - data = bytes.ReplaceAll(data, []byte(secret), []byte("[REDACTED]")) - } - } - if err = os.WriteFile(filepath.Join(root, "remote-adapter-proof.json"), data, 0600); err != nil { - t.Error(err) - } -} diff --git a/services/agents-api/internal/store/native_environment_adapter_test.go b/services/agents-api/internal/store/native_environment_adapter_test.go deleted file mode 100644 index b99152505..000000000 --- a/services/agents-api/internal/store/native_environment_adapter_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativeDaemonRemoteEnvironment(t *testing.T) { - testNativeDaemonRemoteEnvironment(t, false) -} - -func TestNativeDaemonPreparedRemoteEnvironment(t *testing.T) { - testNativeDaemonRemoteEnvironment(t, true) -} - -func testNativeDaemonRemoteEnvironment(t *testing.T, prepared bool) { - testNativeDaemonRemoteEnvironmentWithArtifact(t, prepared, "") -} - -func testNativeDaemonRemoteEnvironmentWithArtifact(t *testing.T, prepared bool, artifactPath string) { - binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - keyFile := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") - if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" { - t.Skip("pinned native binary, local executor image and real provider key file required") - } - version, err := exec.Command(binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native Codex 0.153.4 required") - } - keyBytes, err := os.ReadFile(keyFile) - if err != nil || strings.TrimSpace(string(keyBytes)) == "" { - t.Fatal("real model credential unavailable") - } - key := strings.TrimSpace(string(keyBytes)) - t.Setenv("PARSAR_CODEX_BIN", binary) - var artifact *nativeHarnessArtifact - if artifactPath != "" { - artifact = newNativeHarnessArtifact(t, binary, artifactPath) - t.Setenv("PARSAR_CODEX_HARNESS_BIN", artifactPath) - } - h, ctx, root := nativeDispatchHarnessWithTimeout(t, 8*time.Minute) - peer, err := h.registry.LookupDevice(h.device.ID) - if err != nil { - t.Fatal(err) - } - info, found, known := peer.AgentKindStatus("codex") - if !known || !found || !info.Available || !info.Capabilities.RemoteEnvironment { - t.Fatal("real heartbeat omitted remote capability") - } - prompt := daemonRemotePrompt - if prepared { - if !info.Capabilities.Preparation { - t.Fatal("real heartbeat omitted preparation capability") - } - prompt = daemonPreparedRemotePrompt - } - // These credentials do not exist when the authenticated daemon starts. - principal := store.FixtureExecutorPrincipal(t, h.s, h.tenant) - workspace := "/parsar-daemon-remote-" + uuid.NewString() - configuration, err := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) - if err != nil { - t.Fatal(err) - } - session, err := h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-adapter", Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - environment, err := h.s.GetSessionEnvironment(ctx, h.tenant, session.ID) - if err != nil { - t.Fatal(err) - } - if artifact != nil { - artifact.bind(t, environment.ID, workspace) - } - lease, err := h.s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - credential, err := h.s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: h.s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, h.tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer releaseHarness() - - observation := &relayObservation{} - handler := registry.Handler() - server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: r.URL.Path}, r) - }) - server.Start() - defer func() { registry.Close(); server.Close() }() - memory, instruction := uuid.NewString(), "REMOTE_"+uuid.NewString() - local := prepareDaemonRemoteWorkspace(t, root, instruction) - container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, server.URL, environment.ID, credential) - if artifact != nil { - artifact.container = container - artifact.installDirectoryHelper(t, ctx) - } - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor registration", func() bool { - connected, e := registry.Connected(ctx, h.tenant, environment.ID) - return e == nil && connected - }) - req := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "remote-" + session.ID, WorkDir: filepath.Join(root, "harness"), ReleaseOnCompletion: true, StrictResume: true, DisableSubagents: true, ObserveMessages: true, ObserveToolObservations: true, - AgentOptions: map[string]any{"model": "MiniMax-M3", "web_search": "disabled", "system_prompt": "Follow the user instructions and use the native shell for requested commands.", "codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, - RemoteEnvironment: &proto.RemoteEnvironment{ID: environment.ID, WorkspaceDirectory: workspace, ConnectionURL: server.URL, ConnectionToken: harnessToken}} - proof := map[string]any{"scope": "authenticated daemon adapter; public Environment admission and dispatcher remain pending", "native_version": string(version), "environment_id": environment.ID, "remote_workspace": workspace, "events": []proto.Envelope{}} - proof["prepared_execution"] = prepared - phase := "rejected" - if artifact != nil { - proof["private_harness_artifact"] = artifact.proof - prompt = func(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { - return daemonPreparedRemotePromptWithReady(t, ctx, peer, req, cancelWhen, func(handle string) bool { - artifact.handle, artifact.runID, artifact.peer = handle, "", peer - artifact.observeReady(t, ctx, phase, local) - return true - }, func(run string) { artifact.runID = run }) - } - } - defer persistDaemonRemoteProof(t, root, proof, []string{key, credential.Token, harnessToken, h.credential}) - bad := req - bad.AgentStateKey += "-rejected" - badBinding := *req.RemoteEnvironment - badBinding.ConnectionToken = "invalid-test-token" - bad.RemoteEnvironment = &badBinding - bad.Prompt = "This must fail before a model Turn." - _, rejected, _ := prompt(t, ctx, peer, bad, nil) - if !daemonRemoteHasError(rejected) && !(prepared && daemonRemotePreparationFailed(rejected)) { - t.Fatal("invalid transient authorization accepted") - } - proof["invalid_authorization_rejected"] = true - proof["invalid_authorization_events"] = rejected - for index, currentPhase := range []string{"first", "resumed"} { - phase = currentPhase - observation.mu.Lock() - before := observation.executors - observation.mu.Unlock() - req.Prompt = "Run the exact command `./placement.sh " + phase + "` once with the native shell. The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." - if index == 0 { - req.Prompt += " Remember this memory value: " + memory + "." - } else { - req.Prompt += " Recall the memory value from the first Turn and read retained.txt." - } - done, events, _ := prompt(t, ctx, peer, req, nil) - proof[phase] = map[string]any{"done": done, "events": events} - if daemonRemoteHasError(events) || !strings.Contains(done.Content, memory) || !strings.Contains(done.Content, instruction) || strings.Contains(done.Content, "WRONG_LOCAL_INSTRUCTIONS") { - t.Fatal("remote instructions or cold native memory not observed; inspect private proof") - } - native, ok := done.Metadata[proto.DoneMetaAgentSessionID].(string) - if !ok || native == "" || (index == 1 && native != req.AgentSessionID) { - t.Fatal("native continuation identity changed") - } - req.AgentSessionID = native - assertDaemonRemoteCommand(t, events, phase, workspace) - if data, e := os.ReadFile(filepath.Join(local, phase+".cwd")); e != nil || strings.TrimSpace(string(data)) != workspace { - t.Fatal("command used a different workspace") - } - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after harness release", func() bool { - observation.mu.Lock() - reconnected := observation.executors > before - observation.mu.Unlock() - connected, e := registry.Connected(ctx, h.tenant, environment.ID) - return reconnected && e == nil && connected - }) - if artifact != nil && index == 0 { - observation.mu.Lock() - beforeRead := observation.executors - observation.mu.Unlock() - artifact.observeReadPreparation(t, ctx, peer, req, root) - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after temporary read", func() bool { - observation.mu.Lock() - reconnected := observation.executors > beforeRead - observation.mu.Unlock() - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - return reconnected && err == nil && connected - }) - } - } - count, err := os.ReadFile(filepath.Join(local, "execution-count")) - if err != nil || string(count) != "first\nresumed\n" { - t.Fatal("remote command was omitted or repeated") - } - if data, e := os.ReadFile(filepath.Join(local, "retained.txt")); e != nil || string(data) != "remote-file-content\n" { - t.Fatal("remote file did not persist") - } - req.Prompt = "Run the exact command `./long.sh cancel` using the native shell. It deliberately runs until cancelled. Keep waiting or polling; do not finish this Turn or produce a final answer while it is running." - phase = "cancel" - observation.mu.Lock() - beforeCancel := observation.executors - observation.mu.Unlock() - cancelAt := time.Time{} - _, events, ack := prompt(t, ctx, peer, req, func() bool { - _, e := os.Stat(filepath.Join(local, "cancel.heartbeat")) - if e == nil && cancelAt.IsZero() { - if artifact != nil { - artifact.observeActive(t, ctx, local) - } - cancelAt = time.Now() - return true - } - return false - }) - proof["cancel_events"] = events - proof["cancel_receipt"] = ack - if cancelAt.IsZero() || ack == nil || !ack.Applied || ack.ErrorCode != "" { - t.Fatal("native command was not cancelled through the daemon") - } - awaitDaemonRemoteExit(t, ctx, container, local) - proof["cancel_to_observed_exit_seconds"] = time.Since(cancelAt).Seconds() - if artifact != nil { - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "artifact executor reconnect after cancellation", func() bool { - observation.mu.Lock() - reconnected := observation.executors > beforeCancel - observation.mu.Unlock() - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - return reconnected && err == nil && connected - }) - observation.mu.Lock() - beforeRelease := observation.executors - observation.mu.Unlock() - _, released, _ := daemonPreparedRemotePromptWithReady(t, ctx, peer, req, nil, func(handle string) bool { - artifact.handle, artifact.runID, artifact.peer = handle, "", peer - artifact.observeReady(t, ctx, "after_cancel", local) - return false - }, nil) - if len(released) == 0 || daemonRemotePreparationFailed(released) { - t.Fatal("post-cancel artifact preparation failed") - } - proof["after_cancel_preparation_events"] = released - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "artifact executor reconnect after unused release", func() bool { - observation.mu.Lock() - reconnected := observation.executors > beforeRelease - observation.mu.Unlock() - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - return reconnected && err == nil && connected - }) - artifact.assertReleased(t, ctx) - } - if peer.IsClosed() { - t.Fatal("daemon disconnected during cancellation acceptance") - } - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - if err != nil || !connected { - t.Fatal("registry/executor stopped during cancellation acceptance") - } - if _, err := os.Stat(workspace); !os.IsNotExist(err) { - t.Fatal("remote path was created on the harness host") - } - if _, err := os.Stat(filepath.Join(local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("command inherited credential variables") - } - assertDaemonRemoteSecrets(t, root, req.AgentStateKey, key, credential.Token, harnessToken, h.credential) - if strings.Contains(string(session.Configuration), harnessToken) { - t.Fatal("connection credential reached stored configuration") - } - releaseHarness() - revoked := req - revoked.AgentStateKey += "-revoked" - revoked.AgentSessionID = "" - revoked.Prompt = "This must fail before a model Turn." - _, revokedEvents, _ := prompt(t, ctx, peer, revoked, nil) - if !daemonRemoteHasError(revokedEvents) && !(prepared && daemonRemotePreparationFailed(revokedEvents)) { - t.Fatal("released harness credential still authorized native preparation") - } - proof["dynamic_harness_credential"] = true - proof["released_harness_rejected"] = true - proof["status"] = "daemon_adapter_verified_public_integration_pending" - proof["separate_executor_launcher"] = os.Getenv("PARSAR_EXECUTOR_LAUNCHER") != "" - proof["native_thread_id"] = req.AgentSessionID - proof["harness_token_only_from_typed_prompt"] = true - t.Log("real-provider daemon remote execution evidence", root) -} diff --git a/services/agents-api/internal/store/native_executor_directory_test.go b/services/agents-api/internal/store/native_executor_directory_test.go deleted file mode 100644 index 718afd723..000000000 --- a/services/agents-api/internal/store/native_executor_directory_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativeExecutorDirectoryHelper(t *testing.T) { - probe, helper, proof := os.Getenv("PARSAR_DIRECTORY_PROBE"), os.Getenv("PARSAR_DIRECTORY_HELPER"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") - if probe == "" || helper == "" || proof == "" { - t.Skip("private directory qualification binaries required") - } - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) - defer cancel() - root, err := os.MkdirTemp(proof, "native-directory-") - if err != nil { - t.Fatal(err) - } - local, workspace := filepath.Join(root, "workspace"), "/scoped-directory-"+uuid.NewString() - for _, name := range []string{"workspace/sub", "workspace/empty", "executor/codex", "harness"} { - if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(filepath.Join(local, "retained.txt"), []byte("retained"), 0600); err != nil { - t.Fatal(err) - } - if err := os.Symlink("/etc", filepath.Join(local, "a")); err != nil { - t.Fatal(err) - } - for i := 0; i < 5000; i++ { - if err := os.WriteFile(filepath.Join(local, "sub", strconv.Itoa(i)), []byte("x"), 0600); err != nil { - t.Fatal(err) - } - } - s, _ := store.NewTestStore(t) - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - configuration, _ := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "directory-primitive", Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - server.Config.Handler = registry.Handler() - server.Start() - defer func() { registry.Close(); server.Close() }() - container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), server.URL, environment.ID, credential) - t.Cleanup(func() { _ = os.Remove(filepath.Join(root, "executor", "credential.json")) }) - if err := exec.CommandContext(ctx, "docker", "cp", helper, container+":/usr/local/bin/scoped-directory").Run(); err != nil { - t.Fatal("install qualification helper", err) - } - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor ready", func() bool { - connected, e := registry.Connected(ctx, tenant, environment.ID) - return e == nil && connected - }) - token, release, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer release() - command := exec.CommandContext(ctx, probe) - command.Dir = filepath.Join(root, "harness") - command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "harness"), "PARSAR_NATIVE_ENV_PROOF=" + root, "PARSAR_DIRECTORY_WORKSPACE=" + workspace, "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + token, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} - output, err := command.CombinedOutput() - if err != nil { - message := strings.ReplaceAll(string(output), token, "[redacted]") - message = strings.ReplaceAll(message, credential.Token, "[redacted]") - t.Fatalf("native directory probe: %v: %s", err, message) - } - data, err := os.ReadFile(filepath.Join(root, "directory-native.json")) - if err != nil { - t.Fatal(err) - } - if !json.Valid(data) { - t.Fatal("invalid evidence") - } - t.Log("native directory evidence", root) -} diff --git a/services/agents-api/internal/store/native_harness_artifact_test.go b/services/agents-api/internal/store/native_harness_artifact_test.go deleted file mode 100644 index 1dfb270ba..000000000 --- a/services/agents-api/internal/store/native_harness_artifact_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package store_test - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "net" - "os" - "path/filepath" - "strconv" - "strings" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/google/uuid" -) - -func TestNativeDaemonHarnessArtifact(t *testing.T) { - artifact := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT") - if artifact == "" { - t.Skip("explicit final private harness artifact required") - } - helper := os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") - if !filepath.IsAbs(helper) { - t.Skip("explicit directory helper artifact required") - } - t.Setenv("PARSAR_CODEX_DIRECTORY_HELPER", "/usr/local/bin/agents-api-codex-directory") - testNativeDaemonRemoteEnvironmentWithArtifact(t, true, artifact) -} - -type nativeHarnessArtifact struct { - peer *gateway.Session - handle, runID string - root string - environment string - configuration map[string]string - proof map[string]any - owners map[int]bool - readyOwners []nativeHarnessOwner - container string -} - -type nativeHarnessOwner struct { - PID int `json:"pid"` - IPCRoot string `json:"ipc_root"` - ArtifactSHA256 string `json:"artifact_sha256"` -} - -func newNativeHarnessArtifact(t *testing.T, native, artifact string) *nativeHarnessArtifact { - t.Helper() - if !filepath.IsAbs(native) || !filepath.IsAbs(artifact) { - t.Fatal("artifact and native helper paths must be absolute") - } - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - state, err := filepath.EvalSymlinks(filepath.Join(home, ".parsar")) - if err != nil { - t.Fatal(err) - } - configuration := map[string]string{"native": native, "artifact": artifact, "root": state} - configuration["artifact_sha256"] = nativeHarnessFileHash(t, artifact) - proof := map[string]any{ - "artifact": artifact, "artifact_sha256": configuration["artifact_sha256"], - "native_helper": native, "native_helper_sha256": nativeHarnessFileHash(t, native), - "execution_caller": "existing daemon Codex adapter and Go JSONRPCClient; no launch wrapper", - "preflight": "stock helper discovery; opt-in artifact selected by the native adapter", - "metadata_observations": []map[string]any{}, - "read_observations": []map[string]any{}, - "read_error_observations": []map[string]any{}, - } - return &nativeHarnessArtifact{root: state, configuration: configuration, proof: proof, owners: make(map[int]bool)} -} - -func (a *nativeHarnessArtifact) bind(t *testing.T, environment, workspace string) { - t.Helper() - a.environment = environment - a.configuration["workspace"] = workspace -} - -func (a *nativeHarnessArtifact) current(t *testing.T) nativeHarnessOwner { - t.Helper() - artifact, err := os.Stat(a.configuration["artifact"]) - if err != nil { - t.Fatal(err) - } - entries, err := os.ReadDir("/proc") - if err != nil { - t.Fatal(err) - } - var owners []nativeHarnessOwner - for _, entry := range entries { - pid, err := strconv.Atoi(entry.Name()) - if err != nil || pid <= 1 { - continue - } - process := filepath.Join("/proc", entry.Name()) - executable, err := os.Stat(filepath.Join(process, "exe")) - if err != nil || !os.SameFile(executable, artifact) { - continue - } - data, err := os.ReadFile(filepath.Join(process, "environ")) - if err != nil { - continue - } - var environment, workspace, root string - for _, value := range strings.Split(string(data), "\x00") { - switch { - case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_ENVIRONMENT="): - environment = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_ENVIRONMENT=") - case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_WORKSPACE="): - workspace = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_WORKSPACE=") - case strings.HasPrefix(value, "PARSAR_CODEX_HARNESS_IPC_ROOT="): - root = strings.TrimPrefix(value, "PARSAR_CODEX_HARNESS_IPC_ROOT=") - } - } - if environment != a.environment { - continue - } - if workspace != a.configuration["workspace"] || filepath.Dir(filepath.Dir(root)) != a.root || !strings.HasPrefix(filepath.Base(filepath.Dir(root)), "ch-") { - t.Fatal("adapter private binding differs from the requested Environment") - } - digest := nativeHarnessFileHash(t, filepath.Join(process, "exe")) - if digest != a.configuration["artifact_sha256"] { - t.Fatal("executing artifact identity differs") - } - owners = append(owners, nativeHarnessOwner{PID: pid, IPCRoot: root, ArtifactSHA256: digest}) - } - if len(owners) != 1 { - t.Fatal("expected one actual adapter-owned artifact process", len(owners)) - } - return owners[0] -} - -func (a *nativeHarnessArtifact) observeReady(t *testing.T, ctx context.Context, phase, local string) { - t.Helper() - owner := a.current(t) - if a.owners[owner.PID] { - t.Fatal("fresh preparation reused an earlier harness process") - } - a.owners[owner.PID] = true - a.readyOwners = append(a.readyOwners, owner) - a.proof["distinct_ready_owners"] = len(a.owners) - a.metadata(t, ctx, owner, "ready_"+phase, "placement.sh", local) - if phase == "first" { - a.expectError(t, ctx, owner, a.environment, "retained.txt", "not_found") - } else { - a.metadata(t, ctx, owner, "ready_"+phase, "retained.txt", local) - } - a.expectError(t, ctx, owner, uuid.NewString(), "placement.sh", "wrong_environment") - a.expectError(t, ctx, owner, a.environment, "missing-"+uuid.NewString(), "not_found") - if phase == "first" { - seedNativeHarnessReadFiles(t, local) - } - a.observeReads(t, ctx, owner, "ready_"+phase, local, phase != "first") -} - -func (a *nativeHarnessArtifact) observeActive(t *testing.T, ctx context.Context, local string) { - t.Helper() - owner := a.current(t) - if !a.owners[owner.PID] { - t.Fatal("active metadata did not use the prepared owner") - } - a.metadata(t, ctx, owner, "active_cancel", "retained.txt", local) - a.metadata(t, ctx, owner, "active_cancel", "cancel.started", local) - a.observeReads(t, ctx, owner, "active_cancel", local, true) -} - -func (a *nativeHarnessArtifact) assertReleased(t *testing.T, ctx context.Context) { - t.Helper() - for _, owner := range a.readyOwners { - awaitDaemonRemoteCondition(t, ctx, 10*time.Second, "released metadata endpoint closure", func() bool { - conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "unix", filepath.Join(owner.IPCRoot, "files.sock")) - if err == nil { - _ = conn.Close() - return false - } - return errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) - }) - } - a.proof["released_metadata_endpoints_closed"] = true -} - -func (a *nativeHarnessArtifact) metadata(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, path, local string) { - t.Helper() - response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": path}, 16*1024) - var metadata struct { - Size int64 `json:"size"` - IsFile bool `json:"is_file"` - IsSymlink bool `json:"is_symlink"` - } - info, err := os.Stat(filepath.Join(local, path)) - if err != nil || json.Unmarshal(response.Metadata, &metadata) != nil || response.Error != "" || len(response.Read) != 0 || !metadata.IsFile || metadata.IsSymlink || metadata.Size != info.Size() { - t.Fatal("artifact metadata differs from independently observed remote file", phase, path) - } - observations := a.proof["metadata_observations"].([]map[string]any) - a.proof["metadata_observations"] = append(observations, map[string]any{"phase": phase, "path": path, "owner": owner, "metadata": response.Metadata}) -} - -func (a *nativeHarnessArtifact) expectError(t *testing.T, ctx context.Context, owner nativeHarnessOwner, environment, path, expected string) { - t.Helper() - response := a.request(t, ctx, owner, map[string]any{"environment_id": environment, "path": path}, 16*1024) - if response.Error != expected || len(response.Metadata) != 0 || len(response.Read) != 0 { - t.Fatal("private artifact metadata error differs", expected, response.Error) - } - a.proof[expected+"_verified"] = true -} - -type nativeHarnessMetadataResponse struct { - Metadata json.RawMessage `json:"metadata"` - Read json.RawMessage `json:"read"` - Directory json.RawMessage `json:"directory"` - Error string `json:"error"` -} - -func (a *nativeHarnessArtifact) request(t *testing.T, ctx context.Context, owner nativeHarnessOwner, request map[string]any, responseLimit int64) nativeHarnessMetadataResponse { - t.Helper() - requestCtx, cancel := context.WithTimeout(ctx, 12*time.Second) - defer cancel() - conn, err := (&net.Dialer{}).DialContext(requestCtx, "unix", filepath.Join(owner.IPCRoot, "files.sock")) - if err != nil { - t.Fatal("private artifact file connection failed", err) - } - defer conn.Close() - deadline, _ := requestCtx.Deadline() - if err = conn.SetDeadline(deadline); err != nil { - t.Fatal(err) - } - if err = json.NewEncoder(conn).Encode(request); err != nil { - t.Fatal(err) - } - var response nativeHarnessMetadataResponse - if err = json.NewDecoder(io.LimitReader(conn, responseLimit)).Decode(&response); err != nil { - t.Fatal("private artifact file response failed", err) - } - return response -} - -func nativeHarnessFileHash(t *testing.T, path string) string { - t.Helper() - file, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - hash := sha256.New() - if _, err = io.Copy(hash, file); err != nil { - t.Fatal(err) - } - return hex.EncodeToString(hash.Sum(nil)) -} diff --git a/services/agents-api/internal/store/native_harness_directory_test.go b/services/agents-api/internal/store/native_harness_directory_test.go deleted file mode 100644 index 2a3919e91..000000000 --- a/services/agents-api/internal/store/native_harness_directory_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "testing" -) - -func (a *nativeHarnessArtifact) observeDirectories(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, local string, retained bool) { - t.Helper() - for _, name := range []string{"directory-fixture", "directory-empty"} { - if err := os.MkdirAll(filepath.Join(local, name), 0700); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(filepath.Join(local, "directory-fixture", "child.bin"), []byte{0, 1, 255}, 0600); err != nil { - t.Fatal(err) - } - link := filepath.Join(local, "directory-outside") - outside := "/tmp/parsar-directory-isolation-" + a.environment - if err := exec.CommandContext(ctx, "docker", "exec", a.container, "mkdir", "-p", outside+"/child").Run(); err != nil { - t.Fatal("outside directory fixture was not created", err) - } - if err := exec.CommandContext(ctx, "docker", "exec", a.container, "test", "-d", outside+"/child").Run(); err != nil { - t.Fatal("outside directory fixture does not exist", err) - } - if err := os.Symlink(outside, link); err != nil && !os.IsExist(err) { - t.Fatal(err) - } - type directoryResult struct { - Entries []struct { - Name string `json:"name"` - Kind string `json:"kind"` - Size *int64 `json:"size_bytes"` - } `json:"entries"` - Truncated bool `json:"truncated"` - } - for _, check := range []struct { - path string - limit int - required map[string]int64 - truncated bool - }{ - {"", 4096, map[string]int64{"bounded-read.bin": int64(len(nativeHarnessReadBinary())), "bounded-empty.bin": 0}, false}, - {"directory-fixture", 16, map[string]int64{"child.bin": 3}, false}, - {"directory-empty", 16, map[string]int64{}, false}, - {"", 1, nil, true}, - } { - response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": check.path, "operation": "list_directory", "max_entries": check.limit}, 8<<20) - var directory directoryResult - if response.Error != "" || len(response.Read) != 0 || len(response.Metadata) != 0 || json.Unmarshal(response.Directory, &directory) != nil || directory.Entries == nil || len(directory.Entries) > check.limit || directory.Truncated != check.truncated { - t.Fatal("native directory observation failed", phase, check.path, response.Error, string(response.Directory)) - } - files := make(map[string]int64) - for _, entry := range directory.Entries { - if filepath.Base(entry.Name) != entry.Name { - t.Fatal("directory entry escaped", entry.Name) - } - if entry.Kind == "file" && entry.Size != nil { - files[entry.Name] = *entry.Size - } - } - if check.path == "" && !check.truncated && retained { - check.required["retained.txt"] = int64(len("remote-file-content\n")) - } - for name, size := range check.required { - if got, ok := files[name]; !ok || got != size { - t.Fatal("directory metadata mismatch", phase, name, got, size) - } - } - if check.path == "directory-empty" && len(directory.Entries) != 0 { - t.Fatal("empty directory changed") - } - observations, _ := a.proof["directory_observations"].([]map[string]any) - a.proof["directory_observations"] = append(observations, map[string]any{"phase": phase, "path": check.path, "owner": owner, "result": response.Directory}) - } - for _, path := range []string{"../outside", "/etc", "directory-outside", "directory-outside/child"} { - response := a.request(t, ctx, owner, map[string]any{"environment_id": a.environment, "path": path, "operation": "list_directory", "max_entries": 16}, 16<<10) - if response.Error == "" || len(response.Directory) != 0 { - t.Fatal("directory isolation admitted outside view", path) - } - } - if a.current(t) != owner { - t.Fatal("directory operation replaced native execution owner") - } -} - -func (a *nativeHarnessArtifact) installDirectoryHelper(t *testing.T, ctx context.Context) { - t.Helper() - helper := os.Getenv("PARSAR_DIRECTORY_HELPER_ARTIFACT") - if helper == "" { - return - } - installed := "/usr/local/bin/agents-api-codex-directory" - if err := exec.CommandContext(ctx, "docker", "cp", helper, a.container+":"+installed).Run(); err != nil { - t.Fatal("install directory helper", err) - } - if err := exec.CommandContext(ctx, "docker", "exec", a.container, "chmod", "0555", installed).Run(); err != nil { - t.Fatal("protect directory helper", err) - } - a.proof["directory_helper_sha256"] = nativeHarnessFileHash(t, helper) - a.proof["directory_helper_installed"] = installed -} diff --git a/services/agents-api/internal/store/native_harness_read_test.go b/services/agents-api/internal/store/native_harness_read_test.go deleted file mode 100644 index 822141c15..000000000 --- a/services/agents-api/internal/store/native_harness_read_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/google/uuid" -) - -const nativeHarnessReadMaximum = 8 * 1024 * 1024 - -func nativeHarnessReadBinary() []byte { - data := make([]byte, 2*1024*1024+37) - for index := range data { - data[index] = byte((index*31 + index/251) % 256) - } - return data -} - -func seedNativeHarnessReadFiles(t *testing.T, local string) { - t.Helper() - for path, data := range map[string][]byte{ - "bounded-read.bin": nativeHarnessReadBinary(), - "bounded-empty.bin": {}, - } { - if err := os.WriteFile(filepath.Join(local, path), data, 0600); err != nil { - t.Fatal(err) - } - } -} - -func (a *nativeHarnessArtifact) observeReads(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase, local string, retained bool) { - t.Helper() - if len(a.readyOwners) == 0 || owner != a.readyOwners[len(a.readyOwners)-1] { - t.Fatal("native reads did not use the current prepared owner", phase) - } - binary := nativeHarnessReadBinary() - type readCase struct { - name string - path string - maxBytes int - data []byte - } - cases := []readCase{ - {"full", "bounded-read.bin", nativeHarnessReadMaximum, binary}, - {"exact_bound", "bounded-read.bin", len(binary), binary}, - {"truncated", "bounded-read.bin", 1024*1024 + 17, binary}, - {"minimum_bound", "bounded-read.bin", 1, binary}, - {"empty", "bounded-empty.bin", 1, []byte{}}, - } - if retained { - cases = append(cases, readCase{"native_retained", "retained.txt", 1024, []byte("remote-file-content\n")}) - } - for _, check := range cases { - localData, err := os.ReadFile(filepath.Join(local, check.path)) - if err != nil || !bytes.Equal(localData, check.data) { - t.Fatal("native read backing file differs from expected bytes", phase, check.name) - } - response := a.request(t, ctx, owner, map[string]any{ - "environment_id": a.environment, "path": check.path, "operation": "read", "max_bytes": check.maxBytes, - }, int64(base64.StdEncoding.EncodedLen(check.maxBytes)+1024)) - var read struct { - DataBase64 *string `json:"data_base64"` - Truncated *bool `json:"truncated"` - CloseAcknowledged bool `json:"close_acknowledged"` - } - if response.Error != "" || len(response.Metadata) != 0 || json.Unmarshal(response.Read, &read) != nil || read.DataBase64 == nil || read.Truncated == nil || !read.CloseAcknowledged { - t.Fatal("private native read omitted a valid result or acknowledged close", phase, check.name, response.Error) - } - actual, err := base64.StdEncoding.Strict().DecodeString(*read.DataBase64) - expected := check.data[:min(len(check.data), check.maxBytes)] - truncated := len(check.data) > check.maxBytes - if err != nil || !bytes.Equal(actual, expected) || *read.Truncated != truncated { - t.Fatal("private native read bytes or truncation differ", phase, check.name) - } - digest, sourceDigest := sha256.Sum256(actual), sha256.Sum256(check.data) - observations := a.proof["read_observations"].([]map[string]any) - a.proof["read_observations"] = append(observations, map[string]any{ - "phase": phase, "case": check.name, "path": check.path, "owner": owner, - "source_size": len(check.data), "source_sha256": hex.EncodeToString(sourceDigest[:]), - "max_bytes": check.maxBytes, "bytes_read": len(actual), "sha256": hex.EncodeToString(digest[:]), - "truncated": *read.Truncated, "close_acknowledged": read.CloseAcknowledged, - }) - } - a.observeReadErrors(t, ctx, owner, phase) - a.observeDaemonReads(t, ctx, owner, phase, retained) - a.observeDirectories(t, ctx, owner, phase, local, retained) - if a.current(t) != owner { - t.Fatal("native read phase replaced its prepared execution owner", phase) - } -} - -func (a *nativeHarnessArtifact) observeReadErrors(t *testing.T, ctx context.Context, owner nativeHarnessOwner, phase string) { - t.Helper() - cases := []struct { - name string - environment string - path string - bound any - omitBound bool - expected string - }{ - {"wrong_identity", uuid.NewString(), "bounded-read.bin", 1, false, "wrong_environment"}, - {"missing_file", a.environment, "missing-" + uuid.NewString(), 1, false, "not_found"}, - {"zero_bound", a.environment, "bounded-read.bin", 0, false, "invalid_request"}, - {"negative_bound", a.environment, "bounded-read.bin", -1, false, "invalid_request"}, - {"oversized_bound", a.environment, "bounded-read.bin", nativeHarnessReadMaximum + 1, false, "invalid_request"}, - {"missing_bound", a.environment, "bounded-read.bin", nil, true, "invalid_request"}, - {"null_bound", a.environment, "bounded-read.bin", nil, false, "invalid_request"}, - {"fractional_bound", a.environment, "bounded-read.bin", 1.5, false, "invalid_request"}, - } - for _, check := range cases { - request := map[string]any{"environment_id": check.environment, "path": check.path, "operation": "read"} - if !check.omitBound { - request["max_bytes"] = check.bound - } - response := a.request(t, ctx, owner, request, 16*1024) - if response.Error != check.expected || len(response.Metadata) != 0 || len(response.Read) != 0 { - t.Fatal("private native read error differs", phase, check.name, response.Error) - } - observations := a.proof["read_error_observations"].([]map[string]any) - a.proof["read_error_observations"] = append(observations, map[string]any{ - "phase": phase, "case": check.name, "owner": owner, "error": response.Error, - }) - } -} diff --git a/services/agents-api/internal/store/native_harness_write_test.go b/services/agents-api/internal/store/native_harness_write_test.go deleted file mode 100644 index 448b11def..000000000 --- a/services/agents-api/internal/store/native_harness_write_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativeHarnessFileWrite(t *testing.T) { - artifact, helper, proof := os.Getenv("PARSAR_CODEX_HARNESS_ARTIFACT"), os.Getenv("PARSAR_WRITE_HELPER_ARTIFACT"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") - if artifact == "" || helper == "" || proof == "" { - t.Skip("private harness and file installer artifacts required") - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) - defer cancel() - root, err := os.MkdirTemp(proof, "native-write-") - if err != nil { - t.Fatal(err) - } - local, remote := filepath.Join(root, "environment"), "/write-environment-"+uuid.NewString() - workspace := remote + "/workspace" - for _, name := range []string{"environment/workspace", "environment/staging", "executor/codex", "harness"} { - if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { - t.Fatal(err) - } - } - s, _ := store.NewTestStore(t) - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - configuration, _ := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "private-write", Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - server.Config.Handler = registry.Handler() - server.Start() - defer func() { registry.Close(); server.Close() }() - container := startDaemonRemoteExecutor(t, ctx, root, local, remote, os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), server.URL, environment.ID, credential) - t.Cleanup(func() { _ = os.Remove(filepath.Join(root, "executor", "credential.json")) }) - if err := exec.CommandContext(ctx, "docker", "cp", helper, container+":/usr/local/bin/agents-api-codex-write").Run(); err != nil { - t.Fatal("install qualification helper", err) - } - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor ready", func() bool { - connected, e := registry.Connected(ctx, tenant, environment.ID) - return e == nil && connected - }) - token, release, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer release() - probe, err := filepath.Abs("../../tests/native/write/probe.py") - if err != nil { - t.Fatal(err) - } - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - ipc, err := os.MkdirTemp(filepath.Join(home, ".parsar"), "write-probe-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(ipc) - command := exec.CommandContext(ctx, "python3", probe) - command.Dir = filepath.Join(root, "harness") - command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, - "CODEX_HOME=" + filepath.Join(root, "harness"), - "PARSAR_NATIVE_ENV_PROOF=" + root, "PARSAR_WRITE_LOCAL=" + local, - "PARSAR_CODEX_HARNESS_ARTIFACT=" + artifact, - "PARSAR_CODEX_HARNESS_NATIVE=" + os.Getenv("PARSAR_CODEX_BINARY"), - "PARSAR_CODEX_HARNESS_ENVIRONMENT=" + environment.ID, - "PARSAR_CODEX_HARNESS_WORKSPACE=" + workspace, - "PARSAR_CODEX_HARNESS_STAGING=" + remote + "/staging", - "PARSAR_CODEX_HARNESS_WRITE_HELPER=/usr/local/bin/agents-api-codex-write", - "PARSAR_CODEX_HARNESS_IPC_ROOT=" + filepath.Join(ipc, "native"), - "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, - "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, - "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + token, - "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} - output, err := command.CombinedOutput() - if err != nil { - message := strings.ReplaceAll(string(output), token, "[redacted]") - message = strings.ReplaceAll(message, credential.Token, "[redacted]") - t.Fatalf("native write probe: %v: %s", err, message) - } - data, err := os.ReadFile(filepath.Join(root, "write-native.json")) - if err != nil { - t.Fatal(err) - } - if !json.Valid(data) { - t.Fatal("invalid evidence") - } - t.Log("native write evidence", root) -} diff --git a/services/agents-api/internal/store/native_placement_test.go b/services/agents-api/internal/store/native_placement_test.go deleted file mode 100644 index 2b4221c43..000000000 --- a/services/agents-api/internal/store/native_placement_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativeAppServerRemoteModelPlacement(t *testing.T) { - binary, proof := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") - image, key := os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") - if binary == "" || proof == "" || image == "" || key == "" { - t.Skip("pinned native Codex executable, private evidence directory, executor image and real model key file required") - } - s, _ := store.NewTestStore(t) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - defer cancel() - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - workspace := "/parsar-remote-" + uuid.NewString() - configuration, err := json.Marshal(map[string]any{"environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}}) - if err != nil { - t.Fatal(err) - } - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-placement", Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - executorToken := credential.Token - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer releaseHarness() - - server.Config.Handler = registry.Handler() - server.Start() - defer func() { registry.Close(); server.Close() }() - runtime, err := os.MkdirTemp(proof, "native-placement-") - if err != nil { - t.Fatal(err) - } - script, err := filepath.Abs("../../tests/native/environment_model_probe.py") - if err != nil { - t.Fatal(err) - } - env := []string{"PATH=" + os.Getenv("PATH"), "PARSAR_CODEX_BINARY=" + binary, "PARSAR_PLACEMENT_ROOT=" + runtime, - "PARSAR_PLACEMENT_EXECUTOR_IMAGE=" + image, "PARSAR_PLACEMENT_MODEL_KEY_FILE=" + key, - "PARSAR_PLACEMENT_EXECUTOR_TOKEN=" + executorToken, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, - "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, - "NO_PROXY=127.0.0.1,localhost"} - for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { - if value := os.Getenv(name); value != "" { - env = append(env, name+"="+value) - } - } - container := "parsar-placement-" + uuid.NewString() - env = append(env, "PARSAR_PLACEMENT_WORKSPACE="+workspace, "PARSAR_PLACEMENT_CONTAINER="+container) - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = exec.CommandContext(cleanup, "docker", "rm", "-f", container).Run() - }) - process := startRelayProcess(t, ctx, runtime, env, "python3", script) - select { - case <-process.done: - if process.err != nil { - t.Fatal("real-model native placement failed; inspect private evidence", runtime, process.err) - } - case <-ctx.Done(): - t.Fatal("real-model native placement timed out", runtime) - } - data, err := os.ReadFile(filepath.Join(runtime, "proof.json")) - if err != nil { - t.Fatal(err) - } - var result struct { - Report struct { - Status string `json:"status"` - } `json:"report"` - } - if err = json.Unmarshal(data, &result); err != nil { - t.Fatal(err) - } - if result.Report.Status != "characterized_with_blockers" { - t.Fatal("native placement was not characterized", runtime) - } - t.Log("real-provider native app-server remote placement evidence", runtime) -} diff --git a/services/agents-api/internal/store/native_preparation_helpers_test.go b/services/agents-api/internal/store/native_preparation_helpers_test.go deleted file mode 100644 index 04b266fd5..000000000 --- a/services/agents-api/internal/store/native_preparation_helpers_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -func daemonPreparedRemotePrompt(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { - return daemonPreparedRemotePromptWithReady(t, ctx, peer, req, cancelWhen, nil, nil) -} - -func daemonPreparedRemotePromptWithReady(t *testing.T, ctx context.Context, peer *gateway.Session, req proto.PromptRequestPayload, cancelWhen func() bool, onReady func(string) bool, onStarted func(string)) (proto.DonePayload, []proto.Envelope, *proto.InteractionDecisionAckPayload) { - t.Helper() - request := uuid.NewString() - sub, err := peer.SubscribePreparation(request) - if err != nil { - t.Fatal(err) - } - defer peer.UnsubscribePreparation(request) - configuration := req - configuration.RunID, configuration.Prompt = "", "" - env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, request, proto.ExecutionPreparePayload{Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - if err = peer.Send(ctx, env); err != nil { - t.Fatal(err) - } - var observations []proto.Envelope - await := func(state string) proto.PreparationStatusPayload { - t.Helper() - for { - select { - case <-ctx.Done(): - t.Fatal("real daemon preparation timed out") - case event, ok := <-sub.Events: - if !ok { - t.Fatal("preparation closed before requested state", state, sub.Err()) - } - observations = append(observations, event) - var status proto.PreparationStatusPayload - if event.DecodePayload(&status) != nil { - t.Fatal("invalid preparation status") - } - if status.State == state || status.State == "failed" || status.State == "rejected" || status.State == "expired" { - return status - } - } - } - } - ready := await("ready") - if ready.State != "ready" { - return proto.DonePayload{}, observations, nil - } - if ready.Handle == "" || ready.Revision < 2 || ready.RunID != "" { - t.Fatal("invalid pre-Turn ready identity") - } - if onReady != nil && !onReady(ready.Handle) { - env, err := proto.NewEnvelope(proto.TypeExecutionRelease, request, proto.ExecutionReleasePayload{Handle: ready.Handle}) - if err != nil { - t.Fatal(err) - } - if err = peer.Send(ctx, env); err != nil { - t.Fatal(err) - } - released := await("released") - if released.State != "released" || released.Handle != ready.Handle || released.Revision <= ready.Revision { - t.Fatal("unused native preparation release failed") - } - return proto.DonePayload{}, observations, nil - } - start := func(run string) error { - env, err := proto.NewEnvelope(proto.TypeExecutionStart, request, proto.ExecutionStartPayload{Handle: ready.Handle, RunID: run, Prompt: req.Prompt}) - if err != nil { - return err - } - if err = peer.Send(ctx, env); err != nil { - return err - } - started := await("started") - if started.State != "started" || started.Handle != ready.Handle || started.RunID != run || started.Revision <= ready.Revision { - return errors.New("prepared native transfer failed") - } - if onStarted != nil { - onStarted(run) - } - return nil - } - done, events, ack := daemonRemotePromptWithStart(t, ctx, peer, req, cancelWhen, start) - return done, append(observations, events...), ack -} - -func daemonRemotePreparationFailed(events []proto.Envelope) bool { - for _, event := range events { - var status proto.PreparationStatusPayload - if event.Type == proto.TypePreparationStatus && event.DecodePayload(&status) == nil && status.State == "failed" { - return true - } - } - return false -} diff --git a/services/agents-api/internal/store/native_raw_files_cancel_test.go b/services/agents-api/internal/store/native_raw_files_cancel_test.go deleted file mode 100644 index c6c962262..000000000 --- a/services/agents-api/internal/store/native_raw_files_cancel_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "slices" - "strconv" - "strings" - "testing" - "time" -) - -type rawFilesCancellationProof struct { - Interrupt struct { - RequestID int `json:"request_id"` - Response json.RawMessage `json:"response"` - } `json:"interrupt"` - TurnCompleted json.RawMessage `json:"turn_completed"` - CommandStarted json.RawMessage `json:"command_started"` - CommandCompleted json.RawMessage `json:"command_completed"` - BackgroundBefore json.RawMessage `json:"background_before"` - Termination *struct { - RequestID int `json:"request_id"` - ProcessID string `json:"process_id"` - Response json.RawMessage `json:"response"` - } `json:"termination"` - BackgroundAfter json.RawMessage `json:"background_after"` - FilesAfter struct { - BinaryVerified bool `json:"binary_verified"` - MarkerVerified bool `json:"marker_verified"` - } `json:"files_after"` -} - -type rawFilesRecoveryProof struct { - NativeTurns json.RawMessage `json:"native_turns"` - FilesVerified bool `json:"files_verified"` -} - -func TestNativeRawEnvironmentFilesCancellation(t *testing.T) { - testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ - probeVariable: "PARSAR_RAW_FILES_PROBE", status: "raw_native_files_characterized", - transport: "raw_unix_socket", withCancellation: true, - limitations: "Private first/cancel/fresh Files composition only. Native typed observations may omit or delay interrupted command results. Observed PID exit and stable heartbeat do not establish all-descendant OS quiescence. Public Files, ownership/admission and unbounded native-client backpressure remain open.", - }) -} - -func assertRawFilesCancelActive(t *testing.T, ctx context.Context, container, local string) { - t.Helper() - data, err := os.ReadFile(filepath.Join(local, "cancel.pid")) - if err != nil { - t.Fatal(err) - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil || pid <= 1 { - t.Fatal("invalid active command PID") - } - command := exec.CommandContext(ctx, "docker", "exec", container, "sh", "-c", `kill -0 "$1"`, "--", strconv.Itoa(pid)) - if err := command.Run(); err != nil { - t.Fatal("owned remote command is not active", err) - } - before, err := os.ReadFile(filepath.Join(local, "cancel.heartbeat")) - if err != nil { - t.Fatal(err) - } - awaitDaemonRemoteCondition(t, ctx, 5*time.Second, "active cancel heartbeat", func() bool { - after, err := os.ReadFile(filepath.Join(local, "cancel.heartbeat")) - return err == nil && len(after) > 0 && !bytes.Equal(before, after) - }) -} - -func assertRawFilesCancellation(t *testing.T, proof sharedFilesProbeProof, workspace, local string) { - t.Helper() - cancel := proof.Cancellation - if cancel == nil || proof.TurnStartedCount != 1 || proof.TurnCompletedCount != 1 || proof.CommandStartedCount != 1 || proof.CommandCompletedCount < 0 || proof.CommandCompletedCount > 1 { - t.Fatal("cancel proof lacks actual native lifecycle observations") - } - var response map[string]json.RawMessage - if json.Unmarshal(cancel.Interrupt.Response, &response) != nil || response == nil || len(response) != 0 || cancel.Interrupt.RequestID != 3 { - t.Fatal("native interrupt acknowledgement is missing") - } - var ended struct { - ThreadID string `json:"threadId"` - Turn struct{ ID, Status string } `json:"turn"` - } - if json.Unmarshal(cancel.TurnCompleted, &ended) != nil || ended.ThreadID != proof.NativeThreadID || ended.Turn.ID != proof.NativeTurnID || ended.Turn.Status != "interrupted" { - t.Fatal("native cancellation was not independently observed") - } - var started, completed struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Item struct { - Type, ID, Command, Cwd string - ProcessID string `json:"processId"` - } `json:"item"` - } - if json.Unmarshal(cancel.CommandStarted, &started) != nil || started.ThreadID != proof.NativeThreadID || started.TurnID != proof.NativeTurnID || started.Item.Type != "commandExecution" || started.Item.ID == "" || started.Item.ProcessID == "" || started.Item.Cwd != workspace || !strings.Contains(started.Item.Command, "./shared-gate.sh cancel") { - t.Fatal("cancel target lacks its native command identity") - } - if proof.CommandCompletedCount == 0 { - if string(cancel.CommandCompleted) != "null" { - t.Fatal("missing interrupted command result was invented") - } - } else if json.Unmarshal(cancel.CommandCompleted, &completed) != nil || completed.ThreadID != started.ThreadID || completed.TurnID != started.TurnID || completed.Item.ID != started.Item.ID || (completed.Item.ProcessID != "" && completed.Item.ProcessID != started.Item.ProcessID) { - t.Fatal("interrupted command result changed native identity") - } - var before, after struct { - Data []struct { - ItemID, ProcessID, Command, Cwd string - } `json:"data"` - NextCursor *string `json:"nextCursor"` - } - if json.Unmarshal(cancel.BackgroundBefore, &before) != nil || json.Unmarshal(cancel.BackgroundAfter, &after) != nil || before.NextCursor != nil || after.NextCursor != nil || len(before.Data) > 1 || len(after.Data) != 0 { - t.Fatal("bounded cancellation terminal inventory is incomplete") - } - if len(before.Data) == 0 { - if cancel.Termination != nil { - t.Fatal("absent native command was terminated again") - } - } else { - target := before.Data[0] - var receipt struct{ Terminated bool } - // Native hook text and rendered argv may differ; the Rust fixture checks - // both with the existing shell parser. Preserve both original bodies here. - if target.ItemID != started.Item.ID || target.ProcessID != started.Item.ProcessID || target.Command == "" || target.Cwd != workspace || cancel.Termination == nil || cancel.Termination.RequestID != 5 || cancel.Termination.ProcessID != target.ProcessID || json.Unmarshal(cancel.Termination.Response, &receipt) != nil || !receipt.Terminated { - t.Fatal("native termination lacks exact ownership and acknowledgement") - } - } - files := proof.Files - if !cancel.FilesAfter.BinaryVerified || !cancel.FilesAfter.MarkerVerified || !files.ActiveBinaryVerified || files.ActiveHeartbeat == "" || files.BinaryBytes != 128*1024 || files.MetadataSize != 128*1024 || files.BinarySHA256 != sharedFilesHash(t, filepath.Join(local, "shared-binary.bin")) { - t.Fatal("typed Files were not retained across native cancellation") - } - if !slices.Contains(files.ActiveDirectoryNames, "shared-binary.bin") || !slices.Contains(files.DirectoryNames, "cancel-marker.txt") || !slices.Contains(files.DirectoryNames, "shared-binary.bin") { - t.Fatal("typed directory observations omitted retained files") - } - marker, err := os.ReadFile(filepath.Join(local, "cancel-marker.txt")) - if err != nil || string(marker) != proof.Marker+"\n" { - t.Fatal("post-cancel typed file differs on the executor", err) - } - for _, name := range []string{"cancel.release", "cancel-artifact.txt"} { - if _, err := os.Stat(filepath.Join(local, name)); !os.IsNotExist(err) { - t.Fatal("cancelled gate was released or produced a completed artifact", name) - } - } -} - -func assertRawFilesRecovery(t *testing.T, proof, cancelled sharedFilesProbeProof, local string) { - t.Helper() - if proof.CancellationRecovery == nil || !proof.CancellationRecovery.FilesVerified { - t.Fatal("cold native cancellation recovery was not verified") - } - var page struct { - Data []struct{ ID, Status string } `json:"data"` - NextCursor *string `json:"nextCursor"` - } - if json.Unmarshal(proof.CancellationRecovery.NativeTurns, &page) != nil || page.NextCursor != nil || len(page.Data) != 2 { - t.Fatal("cold native history omitted a prior Turn") - } - found := 0 - for _, turn := range page.Data { - if turn.ID == cancelled.NativeTurnID && turn.Status == "interrupted" { - found++ - } - } - marker, err := os.ReadFile(filepath.Join(local, "cancel-marker.txt")) - if found != 1 || err != nil || string(marker) != cancelled.Marker+"\n" { - t.Fatal("cold native history or post-cancel file changed") - } -} diff --git a/services/agents-api/internal/store/native_relay_test.go b/services/agents-api/internal/store/native_relay_test.go deleted file mode 100644 index a353686d7..000000000 --- a/services/agents-api/internal/store/native_relay_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package store_test - -import ( - "bufio" - "context" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type relayObservation struct { - mu sync.Mutex - harness net.Conn - harnesses int - executors int - connects int - validations int -} - -type relayResponse struct { - http.ResponseWriter - observation *relayObservation - path string -} - -func (w *relayResponse) WriteHeader(status int) { - if status == http.StatusOK { - w.observation.mu.Lock() - if strings.HasSuffix(w.path, "/connect") { - w.observation.connects++ - } - if strings.HasSuffix(w.path, "/validate") { - w.observation.validations++ - } - w.observation.mu.Unlock() - } - w.ResponseWriter.WriteHeader(status) -} -func (w *relayResponse) Hijack() (net.Conn, *bufio.ReadWriter, error) { - conn, buffer, err := w.ResponseWriter.(http.Hijacker).Hijack() - if err == nil { - w.observation.mu.Lock() - if strings.Contains(w.path, "/harness/") { - w.observation.harness = conn - w.observation.harnesses++ - } else { - w.observation.executors++ - } - w.observation.mu.Unlock() - } - return conn, buffer, err -} - -type relayProcess struct { - command *exec.Cmd - done chan struct{} - err error -} - -func startRelayProcess(t *testing.T, ctx context.Context, directory string, env []string, binary string, args ...string) *relayProcess { - t.Helper() - command := exec.CommandContext(ctx, binary, args...) - command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } - command.WaitDelay = 5 * time.Second - command.Dir, command.Env = directory, env - command.Stdout, command.Stderr = io.Discard, io.Discard - if err := command.Start(); err != nil { - t.Fatal(err) - } - process := &relayProcess{command: command, done: make(chan struct{})} - go func() { process.err = command.Wait(); close(process.done) }() - t.Cleanup(func() { - _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) - select { - case <-process.done: - case <-time.After(5 * time.Second): - t.Error("owned native process did not exit") - } - }) - return process -} -func (p *relayProcess) wait(t *testing.T) { - t.Helper() - select { - case <-p.done: - if p.err != nil { - t.Fatal("native relay probe failed", p.err) - } - case <-time.After(100 * time.Second): - t.Fatal("native relay probe timed out") - } -} - -func TestNativeHarnessRelayPostgreSQLAndProcessRecovery(t *testing.T) { - probe, binary, proof := os.Getenv("PARSAR_NATIVE_RELAY_PROBE"), os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_EXECUTOR_PROOF_DIR") - if probe == "" || binary == "" || proof == "" { - t.Skip("pinned native relay probe, Codex binary and private evidence directory required") - } - s, _ := store.NewTestStore(t) - ctx, cancel := context.WithTimeout(t.Context(), 150*time.Second) - defer cancel() - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - defer lease.Close(context.Background()) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "native-relay", Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`)}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(t.Context(), principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - executorToken := credential.Token - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - harnessToken, releaseHarness, err := registry.IssueHarnessCredential(ctx, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - defer releaseHarness() - - observation := &relayObservation{} - handler := registry.Handler() - server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: req.URL.Path}, req) - }) - server.Start() - defer func() { registry.Close(); server.Close() }() - version, err := exec.CommandContext(ctx, binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("pinned Codex0.153.4 required") - } - runtime, err := os.MkdirTemp(proof, "native-relay-") - if err != nil { - t.Fatal(err) - } - for _, sub := range []string{"codex", "workspace"} { - if err := os.Mkdir(filepath.Join(runtime, sub), 0700); err != nil { - t.Fatal(err) - } - } - executorEnv := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "CODEX_HOME=" + filepath.Join(runtime, "codex"), "CODEX_API_KEY=" + executorToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} - startRelayProcess(t, ctx, runtime, executorEnv, binary, "exec-server", "--remote", server.URL, "--environment-id", environment.ID) - until := time.Now().Add(20 * time.Second) - for { - connected, err := registry.Connected(ctx, tenant, environment.ID) - if err == nil && connected { - break - } - if time.Now().After(until) { - t.Fatal("native executor not connected") - } - time.Sleep(20 * time.Millisecond) - } - harnessEnv := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + runtime, "PARSAR_NATIVE_ENV_PROOF=" + runtime, "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off"} - first := startRelayProcess(t, ctx, runtime, harnessEnv, probe, "first") - until = time.Now().Add(50 * time.Second) - for { - if _, err := os.Stat(filepath.Join(runtime, "ready-to-disconnect")); err == nil { - break - } - select { - case <-first.done: - t.Fatal("native probe ended before recovery checkpoint", first.err) - default: - } - if time.Now().After(until) { - t.Fatal("native probe did not reach recovery checkpoint") - } - time.Sleep(20 * time.Millisecond) - } - observation.mu.Lock() - if observation.harnesses != 1 || observation.executors != 1 || observation.connects != 2 || observation.validations != 1 || observation.harness == nil { - observation.mu.Unlock() - t.Fatal("principal concurrency or same-key refresh replaced the active native pair") - } - connection := observation.harness - observation.mu.Unlock() - if err := connection.Close(); err != nil { - t.Fatal(err) - } - first.wait(t) - observation.mu.Lock() - if observation.harnesses != 2 || observation.executors != 2 || observation.validations != 2 { - observation.mu.Unlock() - t.Fatal("both native peers did not reconnect exactly once during controlled recovery") - } - observation.mu.Unlock() - // Wait for the executor's reconnect after the first harness process releases its pair. - until = time.Now().Add(20 * time.Second) - for { - observation.mu.Lock() - executors := observation.executors - observation.mu.Unlock() - if executors >= 3 { - break - } - if time.Now().After(until) { - t.Fatal("executor not ready for fresh harness") - } - time.Sleep(20 * time.Millisecond) - } - fresh := startRelayProcess(t, ctx, runtime, harnessEnv, probe, "fresh") - fresh.wait(t) - firstProof, err := os.ReadFile(filepath.Join(runtime, "first.json")) - if err != nil { - t.Fatal(err) - } - freshProof, err := os.ReadFile(filepath.Join(runtime, "fresh.json")) - if err != nil { - t.Fatal(err) - } - report := map[string]any{"native_executor": "codex 0.153.4", "native_source": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", "real_postgresql": true, "first": json.RawMessage(firstProof), "fresh": json.RawMessage(freshProof), "model_calls": 0, "limitations": []string{"Internal registry/relay workflow, not public Environment admission or model execution", "One independent harness connection per Environment", "One acknowledged-start process survived one bounded transport outage; arbitrary replay and durable crash restoration are not established"}} - data, err := json.MarshalIndent(report, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(runtime, "proof.json"), data, 0600); err != nil { - t.Fatal(err) - } - t.Log("native commands, shared concurrency, 128KiB file, refresh, paired reconnect, one retained process and fresh file retention verified") -} diff --git a/services/agents-api/internal/store/native_shared_files_test.go b/services/agents-api/internal/store/native_shared_files_test.go deleted file mode 100644 index fe04062db..000000000 --- a/services/agents-api/internal/store/native_shared_files_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type sharedFilesProbeProof struct { - Status string `json:"status"` - Transport string `json:"transport"` - Phase string `json:"phase"` - NativeThreadID string `json:"native_thread_id"` - NativeTurnID string `json:"native_turn_id"` - Marker string `json:"marker"` - HistoryValue string `json:"history_value"` - Answer string `json:"answer"` - TurnStartedCount int `json:"turn_started_count"` - TurnCompletedCount int `json:"turn_completed_count"` - CommandStartedCount int `json:"command_started_count"` - CommandCompletedCount int `json:"command_completed_count"` - ShutdownCompleted bool `json:"shutdown_completed"` - Cancellation *rawFilesCancellationProof `json:"cancellation,omitempty"` - CancellationRecovery *rawFilesRecoveryProof `json:"cancellation_recovery,omitempty"` - Command struct { - ID string `json:"id"` - Command string `json:"command"` - Cwd string `json:"cwd"` - AggregatedOutput string `json:"aggregated_output"` - ExitCode int `json:"exit_code"` - Started bool `json:"started"` - Completed bool `json:"completed"` - } `json:"command"` - Files struct { - BinaryBytes int `json:"binary_bytes"` - BinarySHA256 string `json:"binary_sha256"` - MetadataSize int `json:"metadata_size"` - DirectoryNames []string `json:"directory_names"` - ActiveHeartbeat string `json:"active_heartbeat"` - ActiveBinaryVerified bool `json:"active_binary_verified"` - ActiveDirectoryNames []string `json:"active_directory_names"` - Artifact string `json:"artifact"` - } `json:"files"` -} - -type sharedFilesProfile struct { - probeVariable string - status string - transport string - limitations string - withCancellation bool -} - -func TestNativeSharedEnvironmentFiles(t *testing.T) { - testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ - probeVariable: "PARSAR_SHARED_FILES_PROBE", status: "shared_native_files_characterized_with_blockers", - transport: "in_process", - limitations: "The upstream event queue may drop nonrequired events without Lagged. Principal counters/answers/effects characterize this bounded workload, not a lossless production transport, public protocol compatibility, workspace confinement or OS quiescence.", - }) -} - -func TestNativeRawEnvironmentFiles(t *testing.T) { - testNativeSharedEnvironmentFiles(t, sharedFilesProfile{ - probeVariable: "PARSAR_RAW_FILES_PROBE", status: "raw_native_files_characterized", - transport: "raw_unix_socket", - limitations: "The native remote client has an internal unbounded event queue. This finite workflow does not qualify production backpressure, complete output, public Files, idle ownership, authorization/fencing or cancellation.", - }) -} - -func testNativeSharedEnvironmentFiles(t *testing.T, profile sharedFilesProfile) { - probe, binary := os.Getenv(profile.probeVariable), os.Getenv("PARSAR_CODEX_BINARY") - proofDirectory, image := os.Getenv("PARSAR_EXECUTOR_PROOF_DIR"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - keyFile, launcher := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE"), os.Getenv("PARSAR_EXECUTOR_LAUNCHER") - if probe == "" || binary == "" || proofDirectory == "" || image == "" || keyFile == "" || launcher == "" { - t.Skip("built shared-files probe, pinned native installation/launcher/image, private proof and real provider key required") - } - if !strings.HasPrefix(image, "sha256:") { - t.Fatal("pin the preloaded executor image") - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - t.Cleanup(cancel) - version, err := exec.CommandContext(ctx, binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native Codex 0.153.4 required") - } - keyBytes, err := os.ReadFile(keyFile) - if err != nil || strings.TrimSpace(string(keyBytes)) == "" { - t.Fatal("real provider credential unavailable") - } - key := strings.TrimSpace(string(keyBytes)) - callerHome, err := os.UserHomeDir() - if err != nil || !filepath.IsAbs(callerHome) || !filepath.IsAbs(proofDirectory) { - t.Fatal("absolute caller HOME and proof directory required") - } - stateRoot, err := filepath.EvalSymlinks(filepath.Join(callerHome, ".parsar")) - if err != nil { - t.Fatal("resolve caller state directory", err) - } - proofDirectory, err = filepath.EvalSymlinks(proofDirectory) - if err != nil { - t.Fatal("resolve proof directory", err) - } - relative, err := filepath.Rel(stateRoot, proofDirectory) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - t.Fatal("proof directory must resolve under caller ~/.parsar") - } - root, err := os.MkdirTemp(proofDirectory, "shared-files-") - if err != nil { - t.Fatal(err) - } - t.Log("shared native filesystem evidence", root) - instruction := "REMOTE_" + uuid.NewString() - local := prepareDaemonRemoteWorkspace(t, root, instruction) - workspace := "/parsar-shared-files-" + uuid.NewString() - if err := os.WriteFile(filepath.Join(local, "shared-gate.sh"), []byte(sharedFilesGate), 0700); err != nil { - t.Fatal(err) - } - - s, _ := store.NewTestStore(t) - lease, err := s.AcquireExecutionLease(ctx) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) - defer stop() - if err := lease.Close(cleanup); err != nil { - t.Error("execution lease cleanup failed", err) - } - }) - tenant := uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - configuration, err := json.Marshal(map[string]any{"environment": map[string]any{ - "type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}, - }}) - if err != nil { - t.Fatal(err) - } - session, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{ - Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "shared-files", Configuration: configuration, - }) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil { - t.Fatal(err) - } - credential, err := s.IssueExecutorCredential(ctx, principal, environment.ID, environment.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - registry, err := codex.New(codex.Config{ - Store: s, CheckOwnership: lease.Ping, ReplaceConnection: lease.Store().ReplaceEnvironmentConnection, - ObserveConnection: lease.Store().ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String(), - }) - if err != nil { - t.Fatal(err) - } - observation := &relayObservation{} - handler := registry.Handler() - server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - handler.ServeHTTP(&relayResponse{ResponseWriter: w, observation: observation, path: req.URL.Path}, req) - }) - server.Start() - t.Cleanup(func() { registry.Close(); server.Close() }) - container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, server.URL, environment.ID, credential) - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "caller executor connection", func() bool { - connected, err := registry.Connected(ctx, tenant, environment.ID) - return err == nil && connected - }) - proof := map[string]any{ - "scope": "actual PostgreSQL/registry and native shared filesystem; private Session setup, no public file endpoint or daemon cutover", - "environment_id": environment.ID, "session_id": session.ID, "remote_workspace": workspace, - "native_version": strings.TrimSpace(string(version)), "phases": map[string]sharedFilesProbeProof{}, - "relay_observations": map[string]map[string]int{}, - "limitations": profile.limitations, - "transport": profile.transport, - } - secrets := []string{key, credential.Token} - defer func() { persistDaemonRemoteProof(t, root, proof, secrets) }() - var previous sharedFilesProbeProof - processIDs := []int{} - phases := []string{"first", "fresh"} - if profile.withCancellation { - phases = []string{"first", "cancel", "fresh"} - } - for index, phase := range phases { - owner, stopOwner := context.WithCancel(ctx) - t.Cleanup(stopOwner) - harnessToken, releaseHarness, err := registry.IssueHarnessCredential(owner, tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - t.Cleanup(releaseHarness) - secrets = append(secrets, harnessToken) - probeEnvironment := []string{ - "PATH=" + os.Getenv("PATH"), "HOME=" + filepath.Join(root, "harness"), "CODEX_HOME=" + filepath.Join(root, "harness", "codex"), - "PARSAR_SHARED_FILES_ROOT=" + root, "PARSAR_SHARED_FILES_WORKSPACE=" + workspace, - "PARSAR_SHARED_FILES_CALLER_HOME=" + callerHome, "TMPDIR=" + root, - "PARSAR_PLACEMENT_MODEL_KEY_FILE=" + keyFile, "PARSAR_PROBE_MODEL_KEY=" + key, "PARSAR_CODEX_BINARY=" + binary, - "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=" + server.URL, "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=" + environment.ID, - "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=" + harnessToken, "NO_PROXY=127.0.0.1,localhost", "RUST_LOG=off", - } - for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { - if value := os.Getenv(name); value != "" { - probeEnvironment = append(probeEnvironment, name+"="+value) - } - } - process := startPublicNativeProcess(t, ctx, root, phase, probeEnvironment, probe, phase) - processIDs = append(processIDs, process.command.Process.Pid) - t.Cleanup(func() { - select { - case <-process.done: - return - default: - } - _ = os.WriteFile(filepath.Join(local, phase+".release"), []byte("cleanup\n"), 0600) - _ = os.WriteFile(filepath.Join(root, phase+"-active-observed"), []byte("cleanup\n"), 0600) - _ = os.WriteFile(filepath.Join(root, phase+"-release"), []byte("cleanup\n"), 0600) - }) - awaitSharedFilesSignal(t, ctx, process, filepath.Join(local, phase+".heartbeat"), 180*time.Second) - if _, err := os.Stat(filepath.Join(local, phase+".release")); !os.IsNotExist(err) { - t.Fatal("native gate released before independent active observation") - } - proof["relay_observations"].(map[string]map[string]int)[phase+"_active"] = assertSharedFilesPair(t, observation, index+1) - if phase == "cancel" { - assertRawFilesCancelActive(t, ctx, container, local) - } - writeSharedFilesSignal(t, filepath.Join(root, phase+"-active-observed")) - awaitSharedFilesSignal(t, ctx, process, filepath.Join(root, phase+"-ready.json"), 180*time.Second) - checkpoint := readSharedFilesProof(t, filepath.Join(root, phase+"-ready.json")) - assertSharedFilesProof(t, checkpoint, phase, workspace, local, profile) - if phase == "cancel" { - awaitDaemonRemoteExit(t, ctx, container, local) - proof["cancel_process_exit_observed"] = true - } - proof["relay_observations"].(map[string]map[string]int)[phase+"_completed"] = assertSharedFilesPair(t, observation, index+1) - writeSharedFilesSignal(t, filepath.Join(root, phase+"-release")) - select { - case <-process.done: - if process.err != nil { - t.Fatal("shared native probe failed; inspect private phase evidence", phase, process.err) - } - case <-time.After(50 * time.Second): - t.Fatal("shared native owner did not shut down within the fixture bound") - } - result := readSharedFilesProof(t, filepath.Join(root, phase+".json")) - assertSharedFilesProof(t, result, phase, workspace, local, profile) - if phase != "cancel" && !strings.Contains(result.Answer, instruction) { - t.Fatal("native model did not use executor-side instructions") - } - if !result.ShutdownCompleted || result.NativeThreadID != checkpoint.NativeThreadID || result.NativeTurnID != checkpoint.NativeTurnID { - t.Fatal("shutdown proof changed the completed native identity") - } - if index > 0 && (result.NativeThreadID != previous.NativeThreadID || result.NativeTurnID == previous.NativeTurnID || result.HistoryValue != previous.HistoryValue || result.Marker == previous.Marker) { - t.Fatal("fresh native process did not preserve history and distinct Turn/marker identities") - } - if phase == "fresh" && profile.withCancellation { - assertRawFilesRecovery(t, result, previous, local) - } else if result.CancellationRecovery != nil { - t.Fatal("ordinary Files phase unexpectedly reports cancellation recovery") - } - proof["phases"].(map[string]sharedFilesProbeProof)[phase] = result - previous = result - releaseHarness() - stopOwner() - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor reconnect after shared owner release", func() bool { - observation.mu.Lock() - executors, harnesses := observation.executors, observation.harnesses - observation.mu.Unlock() - connected, err := registry.Connected(ctx, tenant, environment.ID) - return executors == index+2 && harnesses == index+1 && err == nil && connected - }) - } - for _, name := range []string{"shared-gate-count", "execution-count"} { - data, err := os.ReadFile(filepath.Join(local, name)) - expected := "first\nfresh\n" - if name == "shared-gate-count" { - expected = strings.Join(phases, "\n") + "\n" - } - if err != nil || string(data) != expected { - t.Fatal("native command was omitted or repeated", name, err) - } - } - if _, err := os.Stat(workspace); !os.IsNotExist(err) { - t.Fatal("executor-only workspace appeared on the harness host") - } - if _, err := os.Stat(filepath.Join(local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("model command inherited a private credential") - } - seenProcesses := map[int]bool{} - for _, pid := range processIDs { - if seenProcesses[pid] { - t.Fatal("cold continuation did not use a fresh probe process") - } - seenProcesses[pid] = true - } - var evidenceFiles []string - for _, phase := range phases { - evidenceFiles = append(evidenceFiles, phase+".log", phase+".json") - } - for _, name := range evidenceFiles { - data, err := os.ReadFile(filepath.Join(root, name)) - if err != nil { - t.Fatal(err) - } - for _, secret := range secrets { - if bytes.Contains(data, []byte(secret)) { - t.Fatal("private credential appeared in probe evidence", name) - } - } - } - proof["probe_process_ids"] = processIDs - proof["probe_sha256"] = sharedFilesHash(t, probe) - proof["native_sha256"] = sharedFilesHash(t, binary) - proof["launcher_sha256"] = sharedFilesHash(t, launcher) - proof["status"] = profile.status -} - -const sharedFilesGate = `#!/bin/sh -set -eu -phase="$1" -case "$phase" in first|cancel|fresh) ;; *) exit 95 ;; esac -for name in PARSAR_PROBE_MODEL_KEY PARSAR_PLACEMENT_MODEL_KEY_FILE CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY MINIMAX_VALIDATION_KEY; do - eval 'value=${'"$name"'-}' - test -z "$value" || { printf '%s\n' "$name" >> credential-failure; exit 23; } -done -pwd > "$phase.cwd" -printf '%s\n' "$phase" >> shared-gate-count -if [ "$phase" = cancel ]; then printf '%s\n' "$$" > cancel.pid; fi -remaining=120 -while [ ! -f "$phase.release" ]; do - test "$remaining" -gt 0 || { printf 'fixture gate timed out\n' >&2; exit 94; } - date +%s > "$phase.heartbeat" - remaining=$((remaining - 1)) - sleep 1 -done -cat "$phase-marker.txt" -cp "$phase-marker.txt" "$phase-artifact.txt" -exec ./placement.sh "$phase" -` - -func awaitSharedFilesSignal(t *testing.T, ctx context.Context, process *relayProcess, path string, timeout time.Duration) { - t.Helper() - awaitDaemonRemoteCondition(t, ctx, timeout, "shared native checkpoint "+filepath.Base(path), func() bool { - select { - case <-process.done: - t.Fatal("shared native probe ended before checkpoint; inspect private phase log", process.err) - default: - } - _, err := os.Stat(path) - return err == nil - }) -} - -func writeSharedFilesSignal(t *testing.T, path string) { - t.Helper() - if err := os.WriteFile(path, []byte("continue\n"), 0600); err != nil { - t.Fatal(err) - } -} - -func readSharedFilesProof(t *testing.T, path string) sharedFilesProbeProof { - t.Helper() - data, err := os.ReadFile(path) - var proof sharedFilesProbeProof - if err != nil || json.Unmarshal(data, &proof) != nil { - t.Fatal("invalid shared filesystem proof", path, err) - } - return proof -} - -func assertSharedFilesPair(t *testing.T, observation *relayObservation, pairs int) map[string]int { - t.Helper() - observation.mu.Lock() - defer observation.mu.Unlock() - if observation.harnesses != pairs || observation.executors != pairs || observation.validations != pairs || observation.harness == nil { - t.Fatal("filesystem/model work replaced or duplicated the authorized native pair") - } - return map[string]int{"harnesses": observation.harnesses, "executors": observation.executors, "validations": observation.validations, "connects": observation.connects} -} - -func assertSharedFilesProof(t *testing.T, proof sharedFilesProbeProof, phase, workspace, local string, profile sharedFilesProfile) { - t.Helper() - if proof.Status != profile.status || proof.Phase != phase || proof.NativeThreadID == "" || proof.NativeTurnID == "" || proof.Marker == "" || proof.HistoryValue == "" || proof.Marker == proof.HistoryValue { - t.Fatal("shared filesystem proof lacks its native identities or random evidence") - } - if profile.transport == "raw_unix_socket" && proof.Transport != profile.transport { - t.Fatal("raw probe did not identify its qualified transport") - } - if phase == "cancel" { - assertRawFilesCancellation(t, proof, workspace, local) - return - } - if proof.Cancellation != nil { - t.Fatal("ordinary Files phase unexpectedly reports cancellation") - } - if proof.TurnStartedCount != 1 || proof.TurnCompletedCount != 1 || proof.CommandStartedCount != 1 || proof.CommandCompletedCount != 1 { - t.Fatal("native probe omitted or repeated principal lifecycle observations") - } - command := proof.Command - if command.ID == "" || !command.Started || !command.Completed || command.ExitCode != 7 || command.Cwd != workspace || !strings.Contains(command.Command, "./shared-gate.sh "+phase) || !strings.Contains(command.AggregatedOutput, proof.Marker) || !strings.Contains(command.AggregatedOutput, "remote-stdout:"+phase) || !strings.Contains(command.AggregatedOutput, "remote-stderr:"+phase) { - t.Fatal("actual remote command evidence is incomplete") - } - if !strings.Contains(proof.Answer, proof.Marker) || !strings.Contains(proof.Answer, proof.HistoryValue) || strings.Contains(proof.Answer, "WRONG_LOCAL_INSTRUCTIONS") { - t.Fatal("model did not use direct filesystem input or retained native history") - } - files := proof.Files - binary, err := os.Stat(filepath.Join(local, "shared-binary.bin")) - if err != nil || binary.Size() != 128*1024 { - t.Fatal("independent binary file size differs", err) - } - if files.BinaryBytes != 128*1024 || files.MetadataSize != 128*1024 || files.BinarySHA256 != sharedFilesHash(t, filepath.Join(local, "shared-binary.bin")) || files.ActiveHeartbeat == "" || files.Artifact != proof.Marker+"\n" { - t.Fatal("direct filesystem bytes, metadata or active observation is incomplete") - } - activeBinaryListed := false - for _, name := range files.ActiveDirectoryNames { - activeBinaryListed = activeBinaryListed || name == "shared-binary.bin" - } - if !files.ActiveBinaryVerified || !activeBinaryListed { - t.Fatal("active native binary/hash/metadata or directory observation is incomplete") - } - for _, expected := range []string{"shared-binary.bin", phase + "-marker.txt", phase + "-artifact.txt"} { - found := false - for _, name := range files.DirectoryNames { - found = found || name == expected - } - if !found { - t.Fatal("native directory observation omitted an actual file", expected) - } - } - for name, expected := range map[string]string{phase + "-marker.txt": proof.Marker + "\n", phase + "-artifact.txt": files.Artifact, phase + ".cwd": workspace + "\n"} { - data, err := os.ReadFile(filepath.Join(local, name)) - if err != nil || string(data) != expected { - t.Fatal("independent executor filesystem evidence differs", name, err) - } - } - entries, err := os.ReadDir(local) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if !entry.Type().IsRegular() { - continue - } - data, err := os.ReadFile(filepath.Join(local, entry.Name())) - if err != nil || bytes.Contains(data, []byte(proof.HistoryValue)) { - t.Fatal("history-only value reached an executor file", err) - } - } -} - -func sharedFilesHash(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(data) - return hex.EncodeToString(digest[:]) -} diff --git a/services/agents-api/internal/store/native_workspace_preparation_test.go b/services/agents-api/internal/store/native_workspace_preparation_test.go deleted file mode 100644 index 944d2c207..000000000 --- a/services/agents-api/internal/store/native_workspace_preparation_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package store_test - -import ( - "context" - "io/fs" - "maps" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (a *nativeHarnessArtifact) observeReadPreparation(t *testing.T, ctx context.Context, peer *gateway.Session, request proto.PromptRequestPayload, root string) { - t.Helper() - info, found, known := peer.AgentKindStatus(request.AgentKind) - if !known || !found || !info.Capabilities.WorkspaceReadPreparation { - t.Fatal("native daemon omitted read preparation capability") - } - stable := filepath.Join(root, "parsar-daemon", "agent-sessions", request.AgentStateKey) - before := nativeReadStateHashes(t, stable) - read := proto.PromptRequestPayload{AgentKind: request.AgentKind, AgentStateKey: request.AgentStateKey, - RemoteEnvironment: request.RemoteEnvironment, StrictResume: true, ReleaseOnCompletion: true, WorkspaceReadOnly: true} - var owner nativeHarnessOwner - var state string - _, events, _ := daemonPreparedRemotePromptWithReady(t, ctx, peer, read, nil, func(handle string) bool { - owner = a.current(t) - var err error - state, err = os.Readlink(filepath.Join("/proc", strconv.Itoa(owner.PID), "cwd")) - if err != nil || !strings.HasPrefix(state, filepath.Join(root, "parsar-daemon", "workspace-read")+"/") { - t.Fatal("native reader did not use owned temporary state") - } - result, err := peer.ListWorkspaceDirectory(ctx, proto.WorkspaceReadPayload{EnvironmentID: a.environment, Handle: handle, MaxEntries: proto.WorkspaceDirectoryMaxEntries}) - if err != nil || result.Outcome != "completed" || result.Directory == nil || result.Directory.Truncated { - t.Fatal("temporary native directory read failed", err, result.Outcome, result.ErrorCode) - } - found := false - for _, entry := range result.Directory.Entries { - if entry.Name == "retained.txt" && entry.Kind == "file" && entry.SizeBytes != nil && *entry.SizeBytes == int64(len("remote-file-content\n")) { - found = true - } - } - if !found { - t.Fatal("read preparation omitted the real-model generated file") - } - return false - }, nil) - if owner.PID == 0 || state == "" || daemonRemotePreparationFailed(events) { - t.Fatal("temporary native preparation did not complete") - } - if _, err := os.Stat(state); !os.IsNotExist(err) { - t.Fatal("release acknowledged before temporary state removal", err) - } - if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(owner.PID))); !os.IsNotExist(err) { - t.Fatal("release acknowledged before native process exit", err) - } - if !maps.Equal(before, nativeReadStateHashes(t, stable)) { - t.Fatal("temporary read changed original configuration or native history") - } - a.proof["read_only_preparation"] = map[string]any{"owner": owner, "released": true, "temporary_state_removed": true, "stable_state_unchanged": true, "real_model_file_observed": true} -} - -func nativeReadStateHashes(t *testing.T, root string) map[string]string { - t.Helper() - result := map[string]string{} - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Type().IsRegular() { - relative, err := filepath.Rel(root, path) - if err != nil { - return err - } - result[relative] = nativeHarnessFileHash(t, path) - } - return nil - }) - if err != nil || len(result) == 0 { - t.Fatal("cannot observe existing native state", err) - } - return result -} diff --git a/services/agents-api/internal/store/prepared_dispatch_failure_test.go b/services/agents-api/internal/store/prepared_dispatch_failure_test.go index 874a70e52..42b167474 100644 --- a/services/agents-api/internal/store/prepared_dispatch_failure_test.go +++ b/services/agents-api/internal/store/prepared_dispatch_failure_test.go @@ -15,7 +15,7 @@ import ( func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { for _, action := range []string{"cancel", "expire", "delete", "prepare-failure", "disconnect"} { t.Run(action, func(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) _, pool := store.NewTestStore(t) result := runPreparedDispatch(h, t.Context(), pending) frame := h.read(proto.TypeExecutionPrepare) @@ -39,8 +39,8 @@ func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { _ = h.conn.Close() } got := awaitPreparedDispatch(t, result) - if got.run.Turn.ID != "" || released.Load() != 1 { - t.Fatal("unready preparation admitted work or retained credentials", got, released.Load()) + if got.run.Turn.ID != "" { + t.Fatal("unready preparation admitted work", got) } if action == "cancel" || action == "expire" { want := store.EnvironmentInputCancelled @@ -56,6 +56,9 @@ func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { if action == "delete" && !errors.Is(got.err, store.ErrNotFound) { t.Fatal("deleted reservation remained accessible", got.err) } + if action != "disconnect" { + assertPreparationReleased(t, h, frame.ID, handle) + } assertEnvironmentExpiryHasNoHistory(t, pool, h.session.ID) if action == "prepare-failure" || action == "disconnect" { stored, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, pending.ID) @@ -70,7 +73,7 @@ func TestPreparedDispatchSettlesOnlyReadyInput(t *testing.T) { func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *testing.T) { for _, action := range []string{"reject", "cancel", "cancel-no-outcome"} { t.Run(action, func(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) result := runPreparedDispatch(h, t.Context(), pending) frame := h.read(proto.TypeExecutionPrepare) handle := acknowledgePreparation(h, frame.ID) @@ -99,9 +102,10 @@ func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *tes if action == "cancel" { want = store.TurnCancelled } - if got.err != nil || got.run.Turn.Status != want || got.run.Turn.ID != start.RunID || released.Load() != 1 { - t.Fatal("Start control did not settle through ordinary completion", got, released.Load()) + if got.err != nil || got.run.Turn.Status != want || got.run.Turn.ID != start.RunID { + t.Fatal("Start control did not settle through ordinary completion", got) } + assertPreparationReleased(t, h, frame.ID, handle) if action == "cancel-no-outcome" { var outcome struct { ErrorCode string `json:"error_code"` @@ -115,10 +119,10 @@ func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *tes } func TestPreparedDispatchRejectsPooledWriterBeforePreparation(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) h.d.Store = h.s got, err := h.d.RunEnvironmentInput(context.Background(), h.tenant, h.session.ID, pending.ID) - if err == nil || got.Turn.ID != "" || released.Load() != 0 { + if err == nil || got.Turn.ID != "" { t.Fatal("pooled writer reached native preparation", got, err) } } @@ -130,7 +134,7 @@ func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { name = "with-outcome" } t.Run(name, func(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) result := runPreparedDispatch(h, t.Context(), pending) prepare := h.read(proto.TypeExecutionPrepare) handle := acknowledgePreparation(h, prepare.ID) @@ -162,9 +166,10 @@ func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { if withOutcome { want, code = store.TurnCancelled, "" } - if got.err != nil || got.run.Turn.Status != want || outcome.ErrorCode != code || released.Load() != 1 { + if got.err != nil || got.run.Turn.Status != want || outcome.ErrorCode != code { t.Fatal("preparation failure replaced the cancellation receipt", got) } + assertPreparationReleased(t, h, prepare.ID, handle) events, err := h.s.ListTurnEvents(t.Context(), h.tenant, h.session.ID, start.RunID, 0, 100) if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/prepared_dispatch_native_test.go b/services/agents-api/internal/store/prepared_dispatch_native_test.go deleted file mode 100644 index eca1bdac0..000000000 --- a/services/agents-api/internal/store/prepared_dispatch_native_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package store_test - -import ( - "context" - "encoding/json" - "errors" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestNativePreparedWorkerRemoteEnvironment(t *testing.T) { - testNativePreparedWorkerRemoteEnvironment(t, false) -} - -func testNativePreparedWorkerRemoteEnvironment(t *testing.T, directoryReads bool) { - binary, image := os.Getenv("PARSAR_CODEX_BINARY"), os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - keyFile := os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") - if binary == "" || !strings.HasPrefix(image, "sha256:") || keyFile == "" || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") == "" { - t.Skip("built executor launcher, pinned native binary and official SDK, local executor image and real provider key file required") - } - version, err := exec.Command(binary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native Codex 0.153.4 required") - } - keyBytes, err := os.ReadFile(keyFile) - if err != nil || strings.TrimSpace(string(keyBytes)) == "" { - t.Fatal("real model credential unavailable") - } - key := strings.TrimSpace(string(keyBytes)) - t.Setenv("PARSAR_CODEX_BIN", binary) - var directoryArtifact *nativeHarnessArtifact - if directoryReads { - directoryArtifact = prepareWorkerDirectoryArtifact(t, binary) - } - h, ctx, root := nativeDispatchHarnessWithTimeout(t, 8*time.Minute) - principal := store.FixtureExecutorPrincipal(t, h.s, h.tenant) - credential, err := h.s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") - if err != nil { - t.Fatal(err) - } - workspace := "/parsar-prepared-dispatch-" + uuid.NewString() - instructions := "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0." - agent := v1.Agent{ID: "agent_" + uuid.NewString(), Model: "MiniMax-M3", Instructions: &instructions, - MultiAgent: v1.MultiAgentConfig{Enabled: false}, Reasoning: v1.Reasoning{}, ServiceTier: "auto", - Text: v1.TextConfig{Format: v1.TextFormat{Type: "text"}, Verbosity: "medium"}, Tools: []json.RawMessage{}} - configuration, err := json.Marshal(map[string]any{ - "agent": agent, - "environment": map[string]any{"type": "self_hosted", "workspace_directory": workspace, "capability_directories": []string{}}, - }) - if err != nil { - t.Fatal(err) - } - h.session, err = h.s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-dispatch", Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - if _, err := h.s.GetSessionDevice(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("native fixture must begin without a device binding", err) - } - environment, err := h.s.GetSessionEnvironment(ctx, h.tenant, h.session.ID) - if err != nil { - t.Fatal(err) - } - server := httptest.NewUnstartedServer(nil) - var registry *codex.Registry - var tokenMu sync.Mutex - secrets := []string{key, credential.Token, h.credential} - harnessTokens := []string{} - h.d.EnvironmentConnection = func(owner context.Context, session store.Session, selected store.Environment) (execution.EnvironmentConnection, error) { - token, release, err := registry.IssueHarnessCredential(owner, session.TenantID, selected.ID) - if err != nil { - return execution.EnvironmentConnection{}, err - } - tokenMu.Lock() - defer tokenMu.Unlock() - harnessTokens = append(harnessTokens, token) - secrets = append(secrets, token) - return execution.EnvironmentConnection{URL: registry.PublicURL(), Token: token, Release: release}, nil - } - h.d.Options = func(context.Context, store.Session) (map[string]any, error) { - return map[string]any{"codex_provider": map[string]any{"name": "MiniMax validation", "base_url": "https://api.minimax.cn/v1", "bearer_token": key, "wire_api": "responses"}}, nil - } - h.d.CloseEnvironmentConnections = func() { - if registry != nil { - registry.Close() - if registry.LifecycleError() != nil { - t.Error("registry connection lifecycle failed") - } - } - } - worker, err := execution.StartWorker(ctx, h.d) - if err != nil { - t.Fatal(err) - } - workerCtx, cancelWorker := context.WithCancel(ctx) - workerDone := make(chan error, 1) - var workerStarted sync.Once - // Capture the offline public snapshot before scheduling the first native preparation. - startWorker := func() { workerStarted.Do(func() { go func() { workerDone <- worker.Run(workerCtx) }() }) } - defer func() { - cancelWorker() - startWorker() - select { - case err := <-workerDone: - if err != nil && err != context.Canceled { - t.Error("worker stopped unexpectedly", err) - } - case <-time.After(15 * time.Second): - t.Error("worker did not release execution ownership") - } - if registry != nil { - registry.Close() - if registry.LifecycleError() != nil { - t.Error("registry connection lifecycle failed") - } - } - server.Close() - }() - registry, err = codex.New(codex.Config{Store: h.s, CheckOwnership: worker.CheckOwnership, ReplaceConnection: worker.ReplaceEnvironmentConnection, ObserveConnection: worker.ObserveEnvironmentConnection, PublicURL: "http://" + server.Listener.Addr().String()}) - if err != nil { - t.Fatal(err) - } - public, publicToken, foreignToken := preparedPublicHandler(t, h, worker, registry) - secrets = append(secrets, publicToken, foreignToken) - server.Config.Handler = public - server.Start() - awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "pending") - instruction := "REMOTE_" + uuid.NewString() - local := prepareDaemonRemoteWorkspace(t, root, instruction) - proof := map[string]any{"scope": "private Session provisioning and input reservation; public Session read/SSE acceptance with caller-started remote execution; public create/input remain gated", "environment_id": environment.ID, "session_id": h.session.ID, "executor_key_id": credential.KeyID, "executor_key_issued_before_session": true, "executor_key_environment_restricted": false, "native_version": strings.TrimSpace(string(version))} - defer func() { tokenMu.Lock(); defer tokenMu.Unlock(); persistDaemonRemoteProof(t, root, proof, secrets) }() - const memory = "walnut heron violet cedar cobalt willow moss iris" - observer := startPreparedPublicObserver(t, ctx, root, map[string]string{ - "base": server.URL, "token": publicToken, "foreign_token": foreignToken, "session_id": h.session.ID, - "agent_id": agent.ID, "environment_id": environment.ID, "workspace_directory": workspace, - "remote_url": registry.PublicURL(), "memory": memory, "instruction": instruction, - }) - defer observer.close() - observer.await(t, ctx, "ready") - nativeID := "" - for index, phase := range []string{"first", "resumed"} { - if index > 0 { - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "executor registration after previous release", func() bool { - connected, err := registry.Connected(ctx, h.tenant, environment.ID) - return err == nil && connected - }) - awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "connected") - } - text := "Run the exact command `./placement.sh " + phase + "` once with the native shell. The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." - if index == 0 { - text += " The fictional festival name to remember is " + memory + "." - } else { - text += " Recall the fictional festival name from the first Turn and read retained.txt." - } - payload, _ := json.Marshal(map[string]string{"text": text}) - pending, err := h.s.ReserveEnvironmentInput(ctx, h.tenant, h.session.ID, phase, []store.Input{{Kind: "message", Payload: payload}}) - if err != nil { - t.Fatal(err) - } - if index == 0 { - connection := observer.await(t, ctx, "waiting") - if connection.RemoteURL != registry.PublicURL() || connection.EnvironmentID != environment.ID { - t.Fatal("public Environment identity differs from the provisioned target") - } - container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, binary, image, connection.RemoteURL, connection.EnvironmentID, credential) - if directoryArtifact != nil { - directoryArtifact.container = container - directoryArtifact.installDirectoryHelper(t, ctx) - } - awaitEnvironmentConnectionState(t, ctx, h.s, h.tenant, environment.ID, "connected") - proof["launcher_remote_url"] = connection.RemoteURL - proof["launcher_environment_id"] = connection.EnvironmentID - startWorker() - } - run := awaitWorkerEnvironmentRun(t, ctx, h.s, h.tenant, pending) - proof[phase] = run - if run.Turn.Status != store.TurnCompleted || len(run.Reservation.Receipts) != 1 { - t.Fatal("native prepared dispatch did not complete; inspect private proof", err) - } - var result execution.Result - if json.Unmarshal(run.Turn.Outcome, &result) != nil || !strings.Contains(result.Done.Content, memory) || !strings.Contains(result.Done.Content, instruction) || strings.Contains(result.Done.Content, "WRONG_LOCAL_INSTRUCTIONS") { - t.Fatal("remote instructions or native memory missing; inspect private proof") - } - bound, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) - if err != nil || bound.Device.ID != h.device.ID || bound.NativeSessionID == "" || (index == 1 && bound.NativeSessionID != nativeID) { - t.Fatal("native continuation identity changed", err) - } - nativeID = bound.NativeSessionID - var events []proto.Envelope - var after int32 - for { - batch, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, run.Turn.ID, after, 100) - if err != nil { - t.Fatal(err) - } - if len(batch) == 0 { - break - } - for _, event := range batch { - events = append(events, proto.Envelope{Type: event.Kind, ID: run.Turn.ID, Payload: event.Payload}) - after = event.Ordinal - } - } - proof[phase+"_events"] = events - assertDaemonRemoteCommand(t, events, phase, workspace) - retry, err := h.s.ReserveEnvironmentInput(ctx, h.tenant, h.session.ID, phase, []store.Input{{Kind: "message", Payload: payload}}) - if err != nil || len(retry.Receipts) != 1 || !retry.Receipts[0].Replayed || retry.Receipts[0].TurnID != run.Turn.ID { - t.Fatal("reservation retry allocated or executed another native preparation") - } - if directoryArtifact != nil && index == 0 { - proof["core_directory_reads"] = verifyWorkerDirectoryReads(t, ctx, h, worker, registry, environment, root) - } - } - observer.finish(t) - credentialData, err := os.ReadFile(filepath.Join(root, "executor", "credential.json")) - if err != nil { - t.Fatal(err) - } - var launcherCredential map[string]json.RawMessage - if err := json.Unmarshal(credentialData, &launcherCredential); err != nil { - t.Fatal(err) - } - if _, restricted := launcherCredential["environment_id"]; restricted { - t.Fatal("principal credential file contains an Environment restriction") - } - var launcherKeyID string - if err := json.Unmarshal(launcherCredential["key_id"], &launcherKeyID); err != nil || launcherKeyID != credential.KeyID { - t.Fatal("launcher credential file lost the stable key ID") - } - count, err := os.ReadFile(filepath.Join(local, "execution-count")) - if err != nil || string(count) != "first\nresumed\n" { - t.Fatal("native command omitted or repeated") - } - if data, err := os.ReadFile(filepath.Join(local, "retained.txt")); err != nil || string(data) != "remote-file-content\n" { - t.Fatal("remote file did not persist") - } - if _, err := os.Stat(workspace); !os.IsNotExist(err) { - t.Fatal("executor path appeared on the harness host") - } - tokenMu.Lock() - tokens := append([]string(nil), harnessTokens...) - tokenMu.Unlock() - expectedTokens := 2 - if directoryArtifact != nil { - expectedTokens += 2 - } - if len(tokens) != expectedTokens { - t.Fatal("worker prepared a reservation more than once") - } - for _, token := range tokens { - assertDaemonRemoteSecrets(t, root, "agents-api-"+h.session.ID, key, credential.Token, token, h.credential) - } - environmentEvents := observer.environmentEvents(t) - verifyEnvironmentEventsWithSDK(t, root, environmentEvents) - proof["environment_events"] = environmentEvents - proof["native_thread_id"] = nativeID - proof["status"] = "private_provisioning_public_read_sse_remote_execution_verified" - proof["public_evidence"] = filepath.Join(observer.directory, "public-environment-proof.json") - proof["completed_turns"] = 2 - proof["reservation_retries_did_not_prepare_or_start"] = true - t.Log("real-provider bound Worker evidence", root) -} diff --git a/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go b/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go deleted file mode 100644 index 16ca8897a..000000000 --- a/services/agents-api/internal/store/prepared_dispatch_public_helpers_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/executor/codex" - "github.com/google/uuid" -) - -func preparedPublicHandler(t *testing.T, h *dispatchHarness, worker *execution.Worker, registry *codex.Registry) (http.Handler, string, string) { - t.Helper() - token, foreign, foreignTenant := uuid.NewString(), uuid.NewString(), uuid.NewString() - auth, err := api.NewAuthenticator([]api.APIKey{ - {OrganizationID: "test-org", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test-org", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, - }) - if err != nil { - t.Fatal(err) - } - public, err := api.NewHandler(h.s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL(registry.PublicURL())) - if err != nil { - t.Fatal(err) - } - mux := http.NewServeMux() - mux.Handle("/cloud/environment/", registry.Handler()) - mux.Handle("/", public) - return mux, token, foreign -} - -type preparedPublicObserver struct { - directory string - command *exec.Cmd - cancel context.CancelFunc - done chan struct{} - err error -} - -type preparedPublicConnection struct { - RemoteURL string `json:"remote_url"` - EnvironmentID string `json:"environment_id"` -} - -func startPreparedPublicObserver(t *testing.T, ctx context.Context, root string, settings map[string]string) *preparedPublicObserver { - t.Helper() - directory := filepath.Join(root, "public-environment") - if err := os.MkdirAll(directory, 0700); err != nil { - t.Fatal(err) - } - log, err := os.OpenFile(filepath.Join(directory, "observer.log"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) - if err != nil { - t.Fatal(err) - } - settings["evidence"] = directory - input, err := json.Marshal(settings) - if err != nil { - _ = log.Close() - t.Fatal(err) - } - ctx, cancel := context.WithCancel(ctx) - command := exec.CommandContext(ctx, os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), "../../tests/official_environment_activity.py") - command.Stdin = bytes.NewReader(input) - command.Stdout, command.Stderr = log, log - if err := command.Start(); err != nil { - cancel() - _ = log.Close() - t.Fatal("public Environment observer failed to start", err) - } - observer := &preparedPublicObserver{directory: directory, command: command, cancel: cancel, done: make(chan struct{})} - go func() { - observer.err = command.Wait() - _ = log.Close() - close(observer.done) - }() - return observer -} - -func (o *preparedPublicObserver) await(t *testing.T, ctx context.Context, name string) preparedPublicConnection { - t.Helper() - var value preparedPublicConnection - awaitDaemonRemoteCondition(t, ctx, 30*time.Second, "public Environment "+name, func() bool { - select { - case <-o.done: - t.Fatal("public Environment observer ended before signal; inspect private log", o.directory, o.err) - default: - } - raw, err := os.ReadFile(filepath.Join(o.directory, name+".json")) - if os.IsNotExist(err) { - return false - } - if err != nil || json.Unmarshal(raw, &value) != nil { - t.Fatal("invalid public Environment observer signal") - } - return true - }) - return value -} - -func (o *preparedPublicObserver) finish(t *testing.T) { - t.Helper() - select { - case <-o.done: - if o.err != nil { - t.Fatal("public Environment SDK/raw/SSE verification failed; inspect private log", o.directory, o.err) - } - case <-time.After(30 * time.Second): - t.Fatal("public Environment observer did not finish", o.directory) - } -} - -func (o *preparedPublicObserver) environmentEvents(t *testing.T) []v1.SessionEvent { - t.Helper() - raw, err := os.ReadFile(filepath.Join(o.directory, "public-environment-proof.json")) - var proof struct { - Events []v1.SessionEvent `json:"sdk_events"` - } - if err != nil || json.Unmarshal(raw, &proof) != nil { - t.Fatal("public Environment event evidence unavailable") - } - events := []v1.SessionEvent{} - for _, event := range proof.Events { - if event.Environment != nil { - events = append(events, event) - } - } - return events -} - -func (o *preparedPublicObserver) close() { - o.cancel() - select { - case <-o.done: - case <-time.After(5 * time.Second): - _ = o.command.Process.Kill() - <-o.done - } -} diff --git a/services/agents-api/internal/store/prepared_dispatch_test.go b/services/agents-api/internal/store/prepared_dispatch_test.go index 3477e896f..984e7833e 100644 --- a/services/agents-api/internal/store/prepared_dispatch_test.go +++ b/services/agents-api/internal/store/prepared_dispatch_test.go @@ -3,9 +3,6 @@ package store_test import ( "context" "encoding/json" - "errors" - "sync" - "sync/atomic" "testing" "time" @@ -20,45 +17,22 @@ type preparedDispatchResult struct { err error } -func preparedDispatchHarness(t *testing.T) (*dispatchHarness, store.EnvironmentInputReservation, *atomic.Int32) { +func preparedDispatchHarness(t *testing.T) (*dispatchHarness, store.EnvironmentInputReservation) { t.Helper() - h := newDispatchHarness(t) - var err error - h.session, err = h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "prepared", Configuration: json.RawMessage(`{"agent":{"model":"test-model","instructions":"Keep this instruction."},"environment":{"type":"self_hosted","workspace_directory":"/executor-workspace"}}`)}) - if err != nil { - t.Fatal(err) - } - if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { - t.Fatal(err) - } + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model","instructions":"Keep this instruction."},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), true) + assertNoRuntimeAllocation(t, h) lease, err := h.s.AcquireExecutionLease(t.Context()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = lease.Close(context.Background()) }) h.d.Store = lease.Store() - peer, err := h.registry.LookupDevice(h.device.ID) - if err != nil { - t.Fatal(err) - } - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, RemoteEnvironment: true}}}}) - awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "preparation capability", func() bool { - info, _, _ := peer.AgentKindStatus("codex") - return info.Capabilities.Preparation && info.Capabilities.RemoteEnvironment - }) - released := &atomic.Int32{} - h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { - if owner.Err() != nil || environment.TenantID != h.tenant || environment.SessionID != session.ID || session.ID != h.session.ID { - return execution.EnvironmentConnection{}, errors.New("incorrect connection owner") - } - var once sync.Once - return execution.EnvironmentConnection{URL: "http://private-registry.test", Token: "synthetic-connection-token", Release: func() { once.Do(func() { released.Add(1) }) }}, nil - } + enableWorkerEnvironment(t, h) pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}}) if err != nil { t.Fatal(err) } - return h, pending, released + return h, pending } func runPreparedDispatch(h *dispatchHarness, ctx context.Context, pending store.EnvironmentInputReservation) <-chan preparedDispatchResult { @@ -103,11 +77,11 @@ func readyPreparedDispatch(t *testing.T, h *dispatchHarness, request, handle str } func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) result := runPreparedDispatch(h, t.Context(), pending) frame := h.read(proto.TypeExecutionPrepare) var prepare proto.ExecutionPreparePayload - if frame.DecodePayload(&prepare) != nil || prepare.Configuration.Prompt != "" || prepare.Configuration.RunID != "" || prepare.Configuration.ConversationID != "" || prepare.Configuration.RemoteEnvironment == nil || prepare.Configuration.RemoteEnvironment.WorkspaceDirectory != "/executor-workspace" || prepare.Configuration.DisableExecutionEnvironment { + if frame.DecodePayload(&prepare) != nil || prepare.Configuration.Prompt != "" || prepare.Configuration.RunID != "" || prepare.Configuration.ConversationID != "" || prepare.Configuration.LocalEnvironment == nil || prepare.Configuration.LocalEnvironment.ID != h.device.EnvironmentID || prepare.Configuration.DisableExecutionEnvironment { t.Fatal("invalid preparation configuration", prepare) } session, err := h.s.GetSession(t.Context(), h.tenant, h.session.ID) @@ -129,18 +103,16 @@ func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T } h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "retained-prepared-native"}}) + completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) - if got.err != nil || got.run.Turn.Status != store.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence || released.Load() != 1 { - t.Fatal("prepared completion", got, released.Load()) + if got.err != nil || got.run.Turn.Status != store.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence { + t.Fatal("prepared completion", got) } + assertPreparationReleased(t, h, frame.ID, handle) bound, err := h.s.GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) if err != nil || bound.NativeSessionID != "retained-prepared-native" { t.Fatal("native identity was not committed", bound, err) } - h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { - t.Error("replay resolved another native connection") - return execution.EnvironmentConnection{}, errors.New("unexpected replay") - } retry, err := h.d.RunEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID) if err != nil || len(retry.Reservation.Receipts) != 2 || !retry.Reservation.Receipts[0].Replayed || retry.Reservation.Receipts[0].TurnID != start.RunID || retry.Turn.ID != "" { t.Fatal("replay executed again", retry, err) @@ -148,35 +120,27 @@ func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T } func TestPreparedDispatchOwnerOutlivesReservationDeadline(t *testing.T) { - h, pending, released := preparedDispatchHarness(t) + h, pending := preparedDispatchHarness(t) _, pool := store.NewTestStore(t) - connection := h.d.EnvironmentConnection - owners := make(chan context.Context, 1) - h.d.EnvironmentConnection = func(owner context.Context, session store.Session, environment store.Environment) (execution.EnvironmentConnection, error) { - owners <- owner - return connection(owner, session, environment) - } parent, cancel := context.WithCancel(context.Background()) defer cancel() result := runPreparedDispatch(h, parent, pending) frame := h.read(proto.TypeExecutionPrepare) - owner := <-owners - if _, ok := owner.Deadline(); ok { - t.Fatal("reservation deadline was imposed on the execution owner") - } handle := acknowledgePreparation(h, frame.ID) start := readyPreparedDispatch(t, h, frame.ID, handle) if _, err := pool.Exec(t.Context(), "UPDATE environment_input_reservations SET deadline=clock_timestamp()-interval '1 second' WHERE id=$1", pending.ID); err != nil { t.Fatal(err) } stored, err := h.d.Store.ExpireEnvironmentInput(t.Context(), h.tenant, h.session.ID, pending.ID) - if err != nil || stored.State != store.EnvironmentInputAdmitted || owner.Err() != nil || released.Load() != 0 { + if err != nil || stored.State != store.EnvironmentInputAdmitted { t.Fatal("admitted execution lost its owner to the pending-input deadline", err) } h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "completed after the reservation deadline"}) + completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) - if got.err != nil || got.run.Turn.Status != store.TurnCompleted || owner.Err() == nil || released.Load() != 1 { - t.Fatal("completion did not settle and release the execution owner", got, released.Load()) + if got.err != nil || got.run.Turn.Status != store.TurnCompleted { + t.Fatal("completion did not settle the execution owner", got) } + assertPreparationReleased(t, h, frame.ID, handle) } diff --git a/services/agents-api/internal/store/public_harness_profile_test.go b/services/agents-api/internal/store/public_harness_profile_test.go deleted file mode 100644 index 5f20a109d..000000000 --- a/services/agents-api/internal/store/public_harness_profile_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "testing" - "time" - - "github.com/BurntSushi/toml" - "github.com/google/uuid" -) - -type publicHarnessProfile struct { - artifact *nativeHarnessArtifact - home string - name string - args []string - settled bool -} - -func newPublicHarnessProfile(t *testing.T, f *publicSelfHostedFixture, native, image, key string) *publicHarnessProfile { - t.Helper() - binary := os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") - if binary == "" { - return nil - } - artifact := newNativeHarnessArtifact(t, native, binary) - home, err := os.MkdirTemp(artifact.root, "ph-") - if err != nil { - t.Fatal(err) - } - profile := &publicHarnessProfile{artifact: artifact, home: home, name: "parsar-public-harness-" + uuid.NewString(), settled: true} - t.Cleanup(func() { - if profile.settled { - if err := os.RemoveAll(home); err != nil { - t.Error("public harness home cleanup failed", err) - } - } - }) - trace := filepath.Join(f.root, "native-exec-trace") - if err := os.Mkdir(trace, 0700); err != nil { - t.Fatal(err) - } - state := filepath.Join(home, ".parsar") - if err := os.Mkdir(state, 0700); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(home, "tmp"), 0700); err != nil { - t.Fatal(err) - } - artifact.root, artifact.configuration["root"] = state, state - artifact.bind(t, f.environmentID, f.workspace) - baseURL := os.Getenv("PARSAR_PLACEMENT_MODEL_BASE_URL") - if baseURL == "" { - baseURL = "https://api.minimax.cn/v1" - } - caFile := "/etc/ssl/certs/ca-certificates.crt" - if info, err := os.Stat(caFile); err != nil || !info.Mode().IsRegular() { - t.Fatal("host CA bundle required for real provider TLS") - } - var config bytes.Buffer - if err := toml.NewEncoder(&config).Encode(map[string]any{ - "model_provider": "public_validation", - "model_providers": map[string]any{"public_validation": map[string]any{ - "name": "Public native validation", "base_url": baseURL, - "env_key": "MINIMAX_VALIDATION_KEY", "wire_api": "responses", - }}, - }); err != nil { - t.Fatal(err) - } - configPath := filepath.Join(f.root, "native-system-config.toml") - if err := os.WriteFile(configPath, config.Bytes(), 0600); err != nil { - t.Fatal(err) - } - environment := map[string]string{ - "HOME": home, "PARSAR_HOME": f.root, "PATH": "/usr/local/bin:/usr/bin:/bin", "TMPDIR": filepath.Join(home, "tmp"), - "PARSAR_CODEX_BIN": "/opt/codex", "PARSAR_CODEX_HARNESS_BIN": "/opt/parsar-codex-harness", - "MINIMAX_VALIDATION_KEY": key, "SSL_CERT_FILE": caFile, - } - var envFile strings.Builder - for name, value := range environment { - if strings.ContainsAny(value, "\r\n") { - t.Fatal("invalid native container environment") - } - fmt.Fprintf(&envFile, "%s=%s\n", name, value) - } - envPath := filepath.Join(f.root, "native-container.env") - t.Cleanup(func() { _ = os.Remove(envPath) }) - if err := os.WriteFile(envPath, []byte(envFile.String()), 0600); err != nil { - t.Fatal(err) - } - profile.args = []string{"run", "--name", profile.name, "--network", "host", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", - "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), "--env-file", envPath, "--workdir", home, - "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777"} - // Explicit flags override Docker client proxy defaults, including env-file replacements. - for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"} { - argument := name - if os.Getenv(name) == "" { - argument += "=" - } - profile.args = append(profile.args, "--env", argument) - } - for _, mount := range [][3]string{ - {native, "/opt/codex", ",readonly"}, {binary, "/opt/parsar-codex-harness", ",readonly"}, - {f.daemonBinary, "/opt/parsar-daemon", ",readonly"}, {configPath, "/etc/codex/config.toml", ",readonly"}, - {caFile, caFile, ",readonly"}, - {trace, trace, ""}, - {home, home, ""}, {filepath.Join(f.root, "parsar-daemon"), filepath.Join(f.root, "parsar-daemon"), ""}, - } { - profile.args = append(profile.args, "--mount", "type=bind,source="+mount[0]+",target="+mount[1]+mount[2]) - } - profile.args = append(profile.args, image, "strace", "-ff", "-e", "trace=execve,execveat", "-s", "256", "-o", filepath.Join(trace, "exec"), "/opt/parsar-daemon", "connect", "--profile", "execution") - return profile -} - -func (p *publicHarnessProfile) start(t *testing.T, f *publicSelfHostedFixture) *relayProcess { - t.Helper() - p.settled = false - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - if err := exec.CommandContext(ctx, "docker", "rm", "-f", p.name).Run(); err != nil { - t.Error("public harness container cleanup failed", err) - return - } - p.settled = true - }) - return startPublicNativeProcess(t, f.ctx, f.root, "daemon", os.Environ(), "docker", p.args...) -} - -func (f *publicSelfHostedFixture) observeHarnessOwner(t *testing.T) { - t.Helper() - if f.harness == nil { - return - } - a := f.harness.artifact - owner := a.current(t) - if a.owners[owner.PID] { - t.Fatal("public continuation reused an earlier harness process") - } - a.owners[owner.PID] = true - a.readyOwners = append(a.readyOwners, owner) - -} - -func (f *publicSelfHostedFixture) assertHarnessReleased(t *testing.T, proof map[string]any) { - t.Helper() - if f.harness == nil { - return - } - a := f.harness.artifact - a.assertReleased(t, f.ctx) - for _, owner := range a.readyOwners { - awaitDaemonRemoteCondition(t, f.ctx, 10*time.Second, "public harness owner release", func() bool { - _, processErr := os.Stat(filepath.Join("/proc", strconv.Itoa(owner.PID))) - _, ipcErr := os.Stat(filepath.Dir(owner.IPCRoot)) - return os.IsNotExist(processErr) && os.IsNotExist(ipcErr) - }) - } - a.proof["launch_observation"] = "complete execve tracing from daemon startup through final public retries; per-process trace files retained" - a.proof["observed_owners"] = a.readyOwners - a.proof["container_image"] = os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE") - a.proof["configuration"] = "task-isolated native system configuration; no harness launch wrapper" - proof["private_harness_artifact"] = a.proof -} - -func (f *publicSelfHostedFixture) nativeStarts() ([]byte, error) { - if f.harness == nil { - return os.ReadFile(filepath.Join(f.root, "native-starts")) - } - paths, err := filepath.Glob(filepath.Join(f.root, "native-exec-trace", "exec.*")) - if err != nil { - return nil, err - } - var starts []string - for _, path := range paths { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - for _, line := range strings.Split(string(data), "\n") { - if !strings.HasPrefix(line, `execve("/opt/parsar-codex-harness", `) { - continue - } - if !strings.HasSuffix(line, " = 0") { - return nil, fmt.Errorf("incomplete or failed native launch observation") - } - pid := strings.TrimPrefix(filepath.Base(path), "exec.") - if _, err := strconv.Atoi(pid); err != nil { - return nil, err - } - starts = append(starts, pid) - } - } - sort.Strings(starts) - return []byte(strings.Join(starts, "\n")), nil -} diff --git a/services/agents-api/internal/store/remote_mcp_credentials_test.go b/services/agents-api/internal/store/remote_mcp_credentials_test.go index 69604ba3b..47f699078 100644 --- a/services/agents-api/internal/store/remote_mcp_credentials_test.go +++ b/services/agents-api/internal/store/remote_mcp_credentials_test.go @@ -1,88 +1,63 @@ package store_test import ( - "context" "encoding/json" - "errors" + "net/http" + "net/http/httptest" "strings" "testing" - "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -func TestRemoteMCPCredentialFailurePrecedesPreparation(t *testing.T) { +func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *testing.T) { for _, mode := range []string{"missing key", "deleted", "tampered"} { t.Run(mode, func(t *testing.T) { - h := newDispatchHarness(t) - configuration, token := mcpBearerWorkerConfiguration(t, h) - enableWorkerEnvironment(t, h) - configuration = strings.Replace(configuration, `"type":"none"`, `"type":"self_hosted","workspace_directory":"/remote"`, 1) - session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-auth-failure", Configuration: json.RawMessage(configuration)}) - if err != nil { - t.Fatal(err) - } - if err = h.s.BindSessionDevice(t.Context(), h.tenant, session.ID, h.device.ID); err != nil { - t.Fatal(err) - } - pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"must not execute"}`)}}) - if err != nil { - t.Fatal(err) - } - var snapshot execution.Snapshot - if json.Unmarshal([]byte(configuration), &snapshot) != nil { - t.Fatal("invalid fixture") - } - binding := snapshot.MCPCredentials[0] - public, pool := store.NewTestStore(t) - executionStore := h.s + s, pool, tenant, vault, credential := selfHostedMCPAdmissionFixture(t) + expected := http.StatusBadRequest switch mode { case "missing key": - executionStore = public + s = store.New(pool) case "deleted": - if _, err := h.s.DeleteCredential(t.Context(), h.tenant, binding.VaultID, binding.CredentialID); err != nil { + if _, err := s.DeleteCredential(t.Context(), tenant, vault.ID, credential.ID); err != nil { t.Fatal(err) } + expected = http.StatusNotFound case "tampered": - if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext,15,get_byte(token_ciphertext,15) # 1) WHERE id=$1", binding.CredentialID); err != nil { + if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext,15,get_byte(token_ciphertext,15) # 1) WHERE id=$1", credential.ID); err != nil { t.Fatal(err) } } - lease, err := executionStore.AcquireExecutionLease(t.Context()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = lease.Close(context.Background()) }) - h.d.Store = lease.Store() - caps := workerEnvironmentCapabilities() - caps.MCPHTTPTools, caps.MCPHTTPBearerAuth, caps.MCPHTTPRemoteEnvironment, caps.MCPHTTPRemoteBearerAuth = true, true, true, true - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) - awaitDaemonRemoteCondition(t, t.Context(), time.Second, "remote authentication capability", func() bool { - peer, _ := h.registry.LookupDevice(h.device.ID) - info, _, _ := peer.AgentKindStatus("codex") - return info.Capabilities.MCPHTTPRemoteBearerAuth - }) - h.d.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { - t.Error("failed secret lookup reached connection setup") - return execution.EnvironmentConnection{}, errors.New("unexpected connection") - } - run, err := h.d.RunEnvironmentInput(t.Context(), h.tenant, session.ID, pending.ID) - if err == nil || run.Turn.ID != "" || strings.Contains(err.Error(), token) { - t.Fatal("failed authentication started work or exposed secret") - } - if mode == "missing key" && !errors.Is(err, store.ErrCredentialStorageUnavailable) { - t.Fatal("missing key failure changed", err) - } - if mode == "deleted" && !errors.Is(err, store.ErrNotFound) { - t.Fatal("deleted binding failure changed", err) - } - current, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, session.ID, pending.ID) - if err != nil || current.State != store.EnvironmentInputPending || len(current.Receipts) != 0 || !current.Deadline.Equal(pending.Deadline) { - t.Fatal("secret failure mutated input", err) + handler := selfHostedMCPAdmissionHandler(t, s, tenant) + for _, initial := range []bool{false, true} { + body := map[string]any{ + "agent": map[string]any{"model": "model", "tools": []any{map[string]any{ + "type": "mcp", "server_label": "tools", "connection_origin": "service", "credential_id": credential.ID, + "transport": map[string]string{"type": "http", "server_url": "https://tools.example/mcp"}, + }}}, + "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, + "vault_ids": []string{vault.ID}, + } + if initial { + body["input"] = "must not execute" + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(string(raw))) + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != expected || strings.Contains(response.Body.String(), "synthetic-token") || strings.Contains(response.Body.String(), "ciphertext") || strings.Contains(response.Body.String(), "mcp_credentials") { + t.Fatal("rejected MCP credential combination admitted or disclosed", response.Code, response.Body) + } + if expected == http.StatusBadRequest && !strings.Contains(response.Body.String(), "environment:none") { + t.Fatal("unsupported placement attempted credential decryption", response.Body) + } + assertSelfHostedMCPRejectionHasNoWrites(t, pool, tenant) } - assertEnvironmentExpiryHasNoHistory(t, pool, session.ID) }) } } diff --git a/services/agents-api/internal/store/remote_mcp_test.go b/services/agents-api/internal/store/remote_mcp_test.go index 1464253a9..99631531d 100644 --- a/services/agents-api/internal/store/remote_mcp_test.go +++ b/services/agents-api/internal/store/remote_mcp_test.go @@ -2,56 +2,33 @@ package store_test import ( "encoding/json" - "errors" "net/http" "net/http/httptest" "strings" "testing" - "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" ) -func TestRemoteMCPCredentialAdmissionAndRejectedWrites(t *testing.T) { - _, pool := store.NewTestStore(t) - cipher, err := credentialcrypto.New([]byte(strings.Repeat("k", 32))) - if err != nil { - t.Fatal(err) - } - s := store.NewWithCredentialCipher(pool, cipher) - tenant := uuid.NewString() - vault, err := s.CreateVault(t.Context(), tenant, store.CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - credential, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, store.CreateStaticCredentialInput{Name: "test", MCPServerURL: "https://tools.example/mcp", Token: "synthetic-token"}) - if err != nil { - t.Fatal(err) - } - auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test", TenantID: tenant, TokenSHA256: device.HashCredential("test-token")}}) - if err != nil { - t.Fatal(err) - } - worker, _ := publicInitialWorker(t, s) - handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://executor.example")) - if err != nil { - t.Fatal(err) - } - for _, mode := range []string{"unattached", "missing", "wrong URL", "foreign Vault", "implicit", "explicit"} { +func TestSelfHostedServiceMCPRejectedWithoutWrites(t *testing.T) { + s, pool, tenant, vault, credential := selfHostedMCPAdmissionFixture(t) + handler := selfHostedMCPAdmissionHandler(t, s, tenant) + + for _, mode := range []string{"unattached", "missing", "wrong URL", "foreign Vault", "anonymous", "implicit", "explicit", "required anonymous", "required bearer"} { for _, initial := range []bool{false, true} { tool := map[string]any{"type": "mcp", "server_label": "tools", "connection_origin": "service", "transport": map[string]string{"type": "http", "server_url": "https://tools.example/mcp"}} - if mode != "implicit" { + if mode != "implicit" && mode != "anonymous" && mode != "required anonymous" { tool["credential_id"] = credential.ID } - body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/remote"}, "vault_ids": []string{vault.ID}} + tool["required"] = strings.HasPrefix(mode, "required") + body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, "vault_ids": []string{vault.ID}} switch mode { - case "unattached": + case "anonymous", "required anonymous", "unattached": body["vault_ids"] = []string{} case "missing": tool["credential_id"] = uuid.NewString() @@ -70,132 +47,69 @@ func TestRemoteMCPCredentialAdmissionAndRejectedWrites(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) - if mode == "implicit" || mode == "explicit" { - if response.Code != http.StatusOK { - t.Fatal("valid remote credential rejected", response.Code, response.Body) - } - var public struct{ ID string } - if json.Unmarshal(response.Body.Bytes(), &public) != nil || public.ID == "" { - t.Fatal("missing Session") - } - session, err := s.GetSession(t.Context(), tenant, public.ID) - var snapshot execution.Snapshot - if err != nil || json.Unmarshal(session.Configuration, &snapshot) != nil || len(snapshot.MCPCredentials) != 1 || snapshot.MCPCredentials[0].CredentialID != credential.ID || strings.Contains(string(session.Configuration), "synthetic-token") { - t.Fatal("frozen credential missing or secret persisted", err) - } - if strings.Contains(response.Body.String(), "mcp_credentials") || strings.Contains(response.Body.String(), "synthetic-token") { - t.Fatal("private authentication exposed") - } - var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1 AND is_initial", public.ID).Scan(&count); err != nil || (count == 1) != initial { - t.Fatal("initial reservation changed", err) - } - continue + expected := http.StatusNotFound + if mode == "anonymous" || mode == "implicit" || mode == "explicit" || strings.HasPrefix(mode, "required") { + expected = http.StatusBadRequest + } + if response.Code != expected { + t.Fatal("self-hosted service MCP admitted or wrong error", mode, response.Code, response.Body) } - if response.Code != http.StatusNotFound { - t.Fatal("invalid reference accepted or wrong error", mode, response.Code, response.Body) + if expected == http.StatusBadRequest && !strings.Contains(response.Body.String(), "environment:none") { + t.Fatal("request rejected outside the service MCP placement boundary", response.Body) } - for _, table := range []string{"sessions", "environments", "turns", "turn_inputs", "session_items", "session_events", "environment_input_reservations"} { - var count int - where := "session_id IN (SELECT id FROM sessions WHERE tenant_id=$1)" - if table == "sessions" { - where = "tenant_id=$1" - } - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM "+table+" WHERE "+where, tenant).Scan(&count); err != nil || count != 0 { - t.Fatal("rejected request wrote execution state", table, count, err) - } + if strings.Contains(response.Body.String(), "synthetic-token") || strings.Contains(response.Body.String(), "ciphertext") || strings.Contains(response.Body.String(), "mcp_credentials") { + t.Fatal("rejected request exposed private authentication") } + + assertSelfHostedMCPRejectionHasNoWrites(t, pool, tenant) } } } -func TestRemoteMCPWorkerRequiresCombinationCapability(t *testing.T) { - for _, profile := range []string{"anonymous", "bearer", "required", "required bearer"} { - authenticated, required := strings.Contains(profile, "bearer"), strings.Contains(profile, "required") - for _, bound := range []bool{false, true} { - t.Run(map[bool]string{false: "selection", true: "bound"}[bound]+"-"+profile, func(t *testing.T) { - h := newDispatchHarness(t) - configuration, token := mcpWorkerConfiguration, "" - if authenticated { - configuration, token = mcpBearerWorkerConfiguration(t, h) - } - if required { - configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) - } - enableWorkerEnvironment(t, h) - configuration = strings.Replace(configuration, `"type":"none"`, `"type":"self_hosted","workspace_directory":"/remote"`, 1) - session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "remote-mcp", Configuration: json.RawMessage(configuration)}) - if err != nil { - t.Fatal(err) - } - pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) - if err != nil { - t.Fatal(err) - } - if bound { - if err := h.s.BindSessionDevice(t.Context(), h.tenant, session.ID, h.device.ID); err != nil { - t.Fatal(err) - } - } - caps := workerEnvironmentCapabilities() - caps.MCPHTTPTools = true - caps.MCPHTTPBearerAuth = true - caps.MCPHTTPRemoteEnvironment = authenticated || required - caps.MCPHTTPRemoteBearerAuth = required && authenticated - heartbeat := func() { - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) - } - heartbeat() - awaitDaemonRemoteCondition(t, t.Context(), time.Second, "MCP capability", func() bool { - peer, _ := h.registry.LookupDevice(h.device.ID) - info, _, _ := peer.AgentKindStatus("codex") - return info.Capabilities.MCPHTTPTools - }) - frames := workerFrames(t, h) - _, stop := startEnvironmentExpiryWorker(t, h.d) - select { - case frame := <-frames: - t.Fatal("old peer received preparation", frame.Type) - case <-time.After(650 * time.Millisecond): - } - current, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, session.ID, pending.ID) - if err != nil || current.State != store.EnvironmentInputPending || len(current.Receipts) != 0 { - t.Fatal("old peer promoted work", err) - } - if !bound { - if _, err := h.s.GetSessionDevice(t.Context(), h.tenant, session.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("old peer bound", err) - } - } - caps.MCPHTTPRequired = required - caps.MCPHTTPRemoteEnvironment = true - caps.MCPHTTPRemoteBearerAuth = authenticated - heartbeat() - frame := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) - var prepare proto.ExecutionPreparePayload - if frame.DecodePayload(&prepare) != nil || prepare.Configuration.RemoteEnvironment == nil || prepare.Configuration.MCPHTTPServers == nil { - t.Fatal("combination missing from preparation") - } - servers := *prepare.Configuration.MCPHTTPServers - if len(servers) != 1 || servers[0].ServerLabel != "tickets" || servers[0].Required != required || servers[0].AllowedTools == nil || len(*servers[0].AllowedTools) != 0 || (servers[0].BearerToken != nil) != authenticated || authenticated && *servers[0].BearerToken != token { - t.Fatal("MCP declaration changed") - } - handle := acknowledgePreparation(h, frame.ID) - h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "ready"}) - started := nextWorkerFrame(t, frames, proto.TypeExecutionStart) - var start proto.ExecutionStartPayload - if started.DecodePayload(&start) != nil || start.Prompt != "first" { - t.Fatal("input changed") - } - h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done"}) - got := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) - if got.Turn.Status != store.TurnCompleted { - t.Fatal("remote MCP work failed") - } - nextWorkerFrame(t, frames, proto.TypeExecutionRelease) - stop() - }) +func selfHostedMCPAdmissionFixture(t *testing.T) (*store.Store, *pgxpool.Pool, string, store.Vault, store.Credential) { + t.Helper() + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New([]byte(strings.Repeat("k", 32))) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + vault, err := s.CreateVault(t.Context(), tenant, store.CreateVaultInput{}) + if err != nil { + t.Fatal(err) + } + credential, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, store.CreateStaticCredentialInput{Name: "test", MCPServerURL: "https://tools.example/mcp", Token: "synthetic-token"}) + if err != nil { + t.Fatal(err) + } + return s, pool, tenant, vault, credential +} + +func selfHostedMCPAdmissionHandler(t *testing.T, s *store.Store, tenant string) http.Handler { + t.Helper() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test", TenantID: tenant, TokenSHA256: device.HashCredential("test-token")}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, "codex", api.WithEnvironmentRemoteURL("https://executor.example")) + if err != nil { + t.Fatal(err) + } + return handler +} + +func assertSelfHostedMCPRejectionHasNoWrites(t *testing.T, pool *pgxpool.Pool, tenant string) { + t.Helper() + + for _, table := range []string{"sessions", "environments", "turns", "turn_inputs", "session_items", "session_events", "environment_input_reservations"} { + var count int + where := "session_id IN (SELECT id FROM sessions WHERE tenant_id=$1)" + if table == "sessions" { + where = "tenant_id=$1" + } + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM "+table+" WHERE "+where, tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("rejected request wrote execution state", table, count, err) } } } diff --git a/services/agents-api/internal/store/runtime_connection_test.go b/services/agents-api/internal/store/runtime_connection_test.go index c0b1cc087..c5c451036 100644 --- a/services/agents-api/internal/store/runtime_connection_test.go +++ b/services/agents-api/internal/store/runtime_connection_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "errors" + "net/http" "net/http/httptest" "net/url" "testing" @@ -69,8 +70,8 @@ func TestManagedRuntimeConnectionTracksAuthenticatedSocket(t *testing.T) { } dial := func(token string) (*websocket.Conn, error) { u, _ := url.Parse(wsURL) - u.RawQuery = url.Values{"device_id": {owner.DeviceID}, "token": {token}, "version": {proto.Version}}.Encode() - conn, response, err := websocket.DefaultDialer.Dial(u.String(), nil) + u.RawQuery = url.Values{"device_id": {owner.DeviceID}, "version": {proto.Version}}.Encode() + conn, response, err := websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": {"Bearer " + token}}) if response != nil && response.Body != nil { response.Body.Close() } diff --git a/services/agents-api/internal/store/runtime_enrollment.go b/services/agents-api/internal/store/runtime_enrollment.go new file mode 100644 index 000000000..fdfdb76b1 --- /dev/null +++ b/services/agents-api/internal/store/runtime_enrollment.go @@ -0,0 +1,89 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// RuntimeEnrollment returns the immutable resource binding, never another secret. +type RuntimeEnrollment struct { + DeviceID string + SessionID string + EnvironmentID string + WorkspaceDirectory string +} + +// EnrollRuntime binds our daemon to one self-hosted Environment. Retries retain +// the same device/key; they cannot replace compute or adopt another native history. +func (s *Store) EnrollRuntime(ctx context.Context, environmentID, credentialHash string) (RuntimeEnrollment, error) { + tenant, err := s.AuthenticateEnvironmentExecutor(ctx, environmentID, credentialHash) + if err != nil { + return RuntimeEnrollment{}, err + } + environment, err := s.GetEnvironment(ctx, tenant, environmentID) + if err != nil { + return RuntimeEnrollment{}, err + } + lookup, err := deviceLookup(tenant, environmentID) + if err != nil { + return RuntimeEnrollment{}, err + } + var result RuntimeEnrollment + err = s.withPublicSession(ctx, tenant, environment.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + // Recheck authority while holding the Session lock, and retain the key + // lock until binding commits so revocation cannot race enrollment. + authority, err := q.AuthorizeRuntimeEnrollment(ctx, sqlc.AuthorizeRuntimeEnrollmentParams{ + EnvironmentID: lookup.ID, TenantID: lookup.TenantID, TokenSha256: credentialHash, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + bound, err := q.EnrollRuntimeDevice(ctx, sqlc.EnrollRuntimeDeviceParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: lookup.TenantID, + EnvironmentID: lookup.ID, ExecutorKeyID: authority.KeyID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ErrDeviceBindingConflict + } + if err != nil { + return err + } + _, err = q.BindSessionDevice(ctx, sqlc.BindSessionDeviceParams{TenantID: lookup.TenantID, ID: session, ID_2: bound.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrDeviceBindingConflict + } + if err != nil { + return err + } + result = RuntimeEnrollment{DeviceID: uuid.UUID(bound.ID.Bytes).String(), SessionID: environment.SessionID, + EnvironmentID: environment.ID, WorkspaceDirectory: authority.WorkspaceDirectory} + return nil + }) + return result, err +} + +// EnrolledRuntimeBinding identifies user-managed compute, without an allocation +// or any promise of live authorization. The Worker rechecks the socket's key. +type EnrolledRuntimeBinding struct { + DeviceID, TenantID, EnvironmentID, SessionID string +} + +func (s *Store) ListEnrolledRuntimeBindings(ctx context.Context) ([]EnrolledRuntimeBinding, error) { + rows, err := s.queries.ListEnrolledRuntimeBindings(ctx) + if err != nil { + return nil, err + } + result := make([]EnrolledRuntimeBinding, 0, len(rows)) + for _, row := range rows { + result = append(result, EnrolledRuntimeBinding{uuid.UUID(row.DeviceID.Bytes).String(), uuid.UUID(row.TenantID.Bytes).String(), uuid.UUID(row.EnvironmentID.Bytes).String(), uuid.UUID(row.SessionID.Bytes).String()}) + } + return result, nil +} diff --git a/services/agents-api/internal/store/runtime_enrollment_connection_test.go b/services/agents-api/internal/store/runtime_enrollment_connection_test.go new file mode 100644 index 000000000..5021e3712 --- /dev/null +++ b/services/agents-api/internal/store/runtime_enrollment_connection_test.go @@ -0,0 +1,138 @@ +package store_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +func TestEnrolledDaemonConnectionRevocationAndRestart(t *testing.T) { + s, pool := store.NewTestStore(t) + principal := store.FixtureExecutorPrincipal(t, s, uuid.NewString()) + session, err := s.CreateSession(t.Context(), principal.TenantID, store.CreateSessionInput{ + Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"fixture"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), + }) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + key, err := s.IssueExecutorCredential(t.Context(), principal, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(key.Token)) + bound, err := s.EnrollRuntime(t.Context(), environment.ID, hex.EncodeToString(digest[:])) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(nil) + wsURL := "ws://" + server.Listener.Addr().String() + "/api/v1/agent-daemon/ws" + handler, registry, err := runtime.NewGateway(s, wsURL) + if err != nil { + t.Fatal(err) + } + server.Config.Handler = handler + server.Start() + t.Cleanup(func() { server.Close(); runtime.CloseConnections(registry) }) + start := func() func() { + worker, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: registry}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + return func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("worker did not stop") + } + } + } + stop := start() + defer func() { + if stop != nil { + stop() + } + }() + connect := func(token string) *websocket.Conn { + u, _ := url.Parse(wsURL) + u.RawQuery = url.Values{"device_id": {bound.DeviceID}, "version": {proto.Version}}.Encode() + conn, resp, err := websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": {"Bearer " + token}}) + if resp != nil { + resp.Body.Close() + } + if err != nil { + t.Fatal("daemon connection rejected") + } + t.Cleanup(func() { conn.Close() }) + return conn + } + await := func(status string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + current, err := s.GetEnvironment(t.Context(), principal.TenantID, environment.ID) + if err == nil && current.Status == status { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("environment did not become %s", status) + } + first := connect(key.Token) + await("connected") + rotated, err := s.RotateExecutorCredential(t.Context(), principal, key.KeyID) + if err != nil { + t.Fatal(err) + } + // No heartbeat is sent: the Worker's authority check must fence the old socket. + await("disconnected") + _ = first.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err = first.ReadMessage(); err == nil { + t.Fatal("rotated socket retained authority") + } + second := connect(rotated.Token) + await("connected") + stop() + stop = nil + // A new Core owner clears prior transport evidence, then observes the same + // live, authorized daemon. No compute allocation or native execution is made. + stop = start() + await("connected") + if err = s.RevokeExecutorCredential(t.Context(), principal, key.KeyID); err != nil { + t.Fatal(err) + } + await("disconnected") + _ = second.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err = second.ReadMessage(); err == nil { + t.Fatal("revoked socket retained authority") + } + var allocations int + if err = pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE environment_id=$1", environment.ID).Scan(&allocations); err != nil || allocations != 0 { + t.Fatal("user Runtime acquired managed allocation", allocations, err) + } + current, err := s.GetSession(t.Context(), principal.TenantID, session.ID) + if err != nil || current.LastTurn != nil { + t.Fatal("connection handling created execution", err) + } +} diff --git a/services/agents-api/internal/store/runtime_enrollment_test.go b/services/agents-api/internal/store/runtime_enrollment_test.go new file mode 100644 index 000000000..02999941d --- /dev/null +++ b/services/agents-api/internal/store/runtime_enrollment_test.go @@ -0,0 +1,152 @@ +package store + +import ( + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func runtimeEnrollmentFixture(t *testing.T, s *Store, p identity.Principal) (Session, Environment, IssuedExecutorCredential) { + t.Helper() + input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") + input.Creator = p.Subject() + session, err := s.CreateSession(t.Context(), p.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), p.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + key, err := s.IssueExecutorCredential(t.Context(), p, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + return session, environment, key +} + +func TestRuntimeEnrollmentAuthorityAndRotation(t *testing.T) { + s, _ := testStore(t) + p := FixtureExecutorPrincipal(t, s, uuid.NewString()) + session, environment, key := runtimeEnrollmentFixture(t, s, p) + ctx := t.Context() + for _, other := range []identity.Principal{ + FixtureExecutorPrincipal(t, s, uuid.NewString()), + {ProjectScope: p.ProjectScope, SubjectKind: p.SubjectKind, SubjectID: "other-user"}, + p, + } { + _, target, _ := runtimeEnrollmentFixture(t, s, other) + if _, err := s.EnrollRuntime(ctx, target.ID, executorDigest(key.Token)); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign target enrollment: %v", err) + } + } + bound, err := s.EnrollRuntime(ctx, environment.ID, executorDigest(key.Token)) + if err != nil || bound.SessionID != session.ID || bound.EnvironmentID != environment.ID || bound.WorkspaceDirectory != "/workspace" { + t.Fatalf("enrollment: %+v %v", bound, err) + } + if again, err := s.EnrollRuntime(ctx, environment.ID, executorDigest(key.Token)); err != nil || again != bound { + t.Fatalf("retry changed binding: %+v %v", again, err) + } + if devices, err := s.ListExecutionDevices(ctx, p.TenantID); err != nil || len(devices) != 0 { + t.Fatalf("enrolled Runtime entered general selection: %v", err) + } + auth := gateway.NewAuthenticator(s) + if _, err := auth.AuthenticateBearer(ctx, bound.DeviceID, key.Token); err != nil { + t.Fatal(err) + } + otherKey, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.EnrollRuntime(ctx, environment.ID, executorDigest(otherKey.Token)); !errors.Is(err, ErrDeviceBindingConflict) { + t.Fatalf("another key replaced binding: %v", err) + } + rotated, err := s.RotateExecutorCredential(ctx, p, key.KeyID) + if err != nil { + t.Fatal(err) + } + if _, err := auth.AuthenticateBearer(ctx, bound.DeviceID, key.Token); !errors.Is(err, gateway.ErrAuthBadCredential) { + t.Fatalf("old key after rotation: %v", err) + } + if _, err := auth.AuthenticateBearer(ctx, bound.DeviceID, rotated.Token); err != nil { + t.Fatal(err) + } + for _, check := range []struct { + token string + denied bool + }{{key.Token, true}, {rotated.Token, false}} { + status, err := s.TouchAgentDaemonHeartbeat(ctx, device.Heartbeat{RuntimeID: bound.DeviceID, CredentialHash: executorDigest(check.token)}) + if err != nil || status.Deleted != check.denied { + t.Fatalf("rotation heartbeat: %+v %v", status, err) + } + } + if again, err := s.EnrollRuntime(ctx, environment.ID, executorDigest(rotated.Token)); err != nil || again != bound { + t.Fatalf("rotation replaced identity: %+v %v", again, err) + } + if err := s.RevokeExecutorCredential(ctx, p, key.KeyID); err != nil { + t.Fatal(err) + } + if _, ok, err := s.GetDeviceCredential(ctx, bound.DeviceID); err != nil || ok { + t.Fatalf("revoked key authenticates: %v", err) + } + if _, err := s.GetSessionDevice(ctx, p.TenantID, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("revoked binding dispatchable: %v", err) + } +} + +func TestRuntimeEnrollmentConcurrentAndDeletion(t *testing.T) { + s, pool := testStore(t) + p := FixtureExecutorPrincipal(t, s, uuid.NewString()) + session, environment, key := runtimeEnrollmentFixture(t, s, p) + var wg sync.WaitGroup + results := make(chan RuntimeEnrollment, 6) + failures := make(chan error, 6) + for range 6 { + wg.Add(1) + go func() { + defer wg.Done() + v, e := s.EnrollRuntime(t.Context(), environment.ID, executorDigest(key.Token)) + results <- v + failures <- e + }() + } + wg.Wait() + close(results) + close(failures) + for err := range failures { + if err != nil { + t.Fatal(err) + } + } + var bound RuntimeEnrollment + for got := range results { + if bound.DeviceID == "" { + bound = got + } + if got != bound { + t.Fatal("concurrent enrollment created multiple identities") + } + } + var allocations int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE environment_id=$1", environment.ID).Scan(&allocations); err != nil || allocations != 0 { + t.Fatalf("enrollment allocated compute: %d %v", allocations, err) + } + if err := s.DeleteSession(t.Context(), p.TenantID, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.EnrollRuntime(t.Context(), environment.ID, executorDigest(key.Token)); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted enrollment: %v", err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), bound.DeviceID); err != nil || ok { + t.Fatalf("deleted Session authenticates: %v", err) + } + status, err := s.TouchAgentDaemonHeartbeat(t.Context(), device.Heartbeat{RuntimeID: bound.DeviceID, CredentialHash: executorDigest(key.Token)}) + if err != nil || !status.Deleted { + t.Fatalf("deleted heartbeat: %+v %v", status, err) + } +} diff --git a/services/agents-api/internal/store/runtime_wait_test.go b/services/agents-api/internal/store/runtime_wait_test.go new file mode 100644 index 000000000..ecfdb581c --- /dev/null +++ b/services/agents-api/internal/store/runtime_wait_test.go @@ -0,0 +1,27 @@ +package store_test + +import ( + "context" + "testing" + "time" +) + +func awaitDaemonRemoteCondition(t *testing.T, ctx context.Context, timeout time.Duration, label string, ready func() bool) { + t.Helper() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for { + if ready() { + return + } + select { + case <-ctx.Done(): + t.Fatal(label, "context expired") + case <-deadline.C: + t.Fatal(label, "timed out") + case <-tick.C: + } + } +} diff --git a/services/agents-api/internal/store/self_hosted_functions_public_test.go b/services/agents-api/internal/store/self_hosted_functions_public_test.go deleted file mode 100644 index a87876df1..000000000 --- a/services/agents-api/internal/store/self_hosted_functions_public_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "os" - "os/exec" - "reflect" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestSelfHostedFunctionsOfficialClient(t *testing.T) { - python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") - if python == "" { - t.Skip("pinned official Python SDK required") - } - s, pool := store.NewTestStore(t) - tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() - auth, err := api.NewAuthenticator([]api.APIKey{ - {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "function-caller", TokenSHA256: device.HashCredential(token), TenantID: tenant}, - {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "function-caller", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - if err != nil { - t.Fatal(err) - } - worker, _ := publicInitialWorker(t, s) - handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://offline-executor.example")) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - settings := map[string]any{"base": server.URL, "token": token, "foreign_token": foreign} - run := func(phase string) json.RawMessage { - t.Helper() - settings["phase"] = phase - input, err := json.Marshal(settings) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) - defer cancel() - command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_functions.py") - command.Stdin = bytes.NewReader(input) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("public self-hosted functions %s: %v %s", phase, err, output) - } - if !json.Valid(output) { - t.Fatal("invalid public function fixture result") - } - return output - } - accepted := run("create") - var created struct { - ID string `json:"id"` - SavedID string `json:"saved_id"` - InitialID string `json:"initial_id"` - LaterID string `json:"later_id"` - } - if err := json.Unmarshal(accepted, &created); err != nil || created.ID == "" || created.SavedID == "" || created.InitialID == "" || created.LaterID == "" { - t.Fatal("missing public function fixture identities", err) - } - settings["accepted"] = accepted - var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 4 { - t.Fatal("unsupported function configuration persisted a Session", count, err) - } - if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, "controlled-later-input", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Retain pending input."}`)}}); err != nil { - t.Fatal(err) - } - snapshot := func(sessionID string) string { - t.Helper() - var value string - err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object( - 'session', (SELECT to_jsonb(s) FROM sessions s WHERE id=$1), - 'reservations', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM environment_input_reservations r WHERE session_id=$1), - 'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM turns t WHERE session_id=$1), - 'inputs', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sequence) FROM turn_inputs i WHERE session_id=$1), - 'calls', (SELECT jsonb_agg(to_jsonb(c) ORDER BY c.turn_id,c.call_id) FROM function_calls c WHERE session_id=$1), - 'items', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.id) FROM session_items i WHERE session_id=$1), - 'events', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM session_events e WHERE session_id=$1))::text`, sessionID).Scan(&value) - if err != nil { - t.Fatal(err) - } - return value - } - transition := func(session, turn, from, to string) { - t.Helper() - if _, err := s.TransitionTurn(t.Context(), tenant, session, turn, store.TurnTransition{ExpectedStatus: from, Status: to}); err != nil { - t.Fatal(err) - } - } - start := func(session string, nativeCalls []string) (string, []string) { - t.Helper() - // Calls and observations are controlled callbacks, not daemon or model execution. - input, err := s.SubmitMessage(t.Context(), tenant, session, uuid.NewString(), json.RawMessage(`{"text":"Controlled function work."}`)) - if err != nil { - t.Fatal(err) - } - transition(session, input.TurnID, store.TurnQueued, store.TurnInProgress) - var calls []string - for _, native := range nativeCalls { - id := items.Identity(input.TurnID, "tool:"+native) - if err := s.RecordFunctionCall(t.Context(), tenant, session, input.TurnID, store.FunctionCall{CallID: id, ExecutorCallID: native, Name: "lookup_ticket", Arguments: json.RawMessage(`{"ticket":"42"}`)}); err != nil { - t.Fatal(err) - } - calls = append(calls, id) - } - return input.TurnID, calls - } - first, calls := start(created.ID, []string{"a", "b", "c"}) - other, otherCalls := start(created.SavedID, []string{"other"}) - settings["turn_id"], settings["calls"] = first, calls - settings["other_turn"], settings["other_call"] = other, otherCalls[0] - unchanged := map[string]string{created.SavedID: snapshot(created.SavedID), created.InitialID: snapshot(created.InitialID), created.LaterID: snapshot(created.LaterID)} - before := snapshot(created.ID) - run("reject") - if snapshot(created.ID) != before { - t.Fatal("rejected result batch changed calls, receipts, activity or history") - } - submitted := run("submit") - var result struct { - Batch []map[string]any `json:"batch"` - } - if err := json.Unmarshal(submitted, &result); err != nil || len(result.Batch) != 3 { - t.Fatal("missing submitted result batch", err) - } - settings["accepted"] = submitted - history, err := s.ListTurnInputs(t.Context(), tenant, created.ID, first, 0, 100) - if err != nil || len(history) != 4 || history[0].Kind != "message" { - t.Fatal("same-key concurrency duplicated result receipts", err) - } - for index, callID := range calls { - call, err := s.GetFunctionCall(t.Context(), tenant, created.ID, first, callID) - if err != nil || call.Applied { - t.Fatal("HTTP admission invented native application", err) - } - expected := result.Batch[index] - delete(expected, "type") - delete(expected, "turn_id") - delete(expected, "call_id") - var actual map[string]any - if err := json.Unmarshal(call.Result, &actual); err != nil || !reflect.DeepEqual(actual, expected) { - t.Fatal("result omission, null, output order or error changed", err) - } - var input store.FunctionResultInput - if err := json.Unmarshal(history[index+1].Payload, &input); err != nil || input.CallID != callID || input.TurnID != first || history[index+1].Kind != "tool_result" { - t.Fatal("result batch order or target changed", err) - } - if err := s.ConfirmFunctionResult(t.Context(), tenant, created.ID, first, callID); err != nil { - t.Fatal(err) - } - } - var observations []store.ExecutionEvent - for _, native := range []string{"a", "b", "c"} { - observations = append(observations, store.ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(fmt.Sprintf(`{"id":%q,"stage":"after","observation":{"status":"completed","kind":"function","name":"lookup_ticket","arguments":{"ticket":"42"},"content":[{"type":"input_text","text":"normalized native output"}]}}`, native))}) - } - if err := s.AppendTurnEvents(t.Context(), tenant, created.ID, first, 1, observations); err != nil { - t.Fatal(err) - } - transition(created.ID, first, store.TurnInProgress, store.TurnCompleted) - before = snapshot(created.ID) - run("terminal") - if snapshot(created.ID) != before { - t.Fatal("terminal result retry changed the original receipt or history") - } - next, nextCalls := start(created.ID, []string{"next"}) - settings["next_turn"], settings["next_call"] = next, nextCalls[0] - before = snapshot(created.ID) - run("later") - if snapshot(created.ID) != before { - t.Fatal("prior result retry changed later work or old receipts") - } - for id, expected := range unchanged { - if snapshot(id) != expected { - t.Fatal("result submission changed another Session or pending reservation") - } - } - transition(created.ID, next, store.TurnWaiting, store.TurnFailed) - transition(created.SavedID, other, store.TurnWaiting, store.TurnFailed) -} diff --git a/services/agents-api/internal/store/self_hosted_initial_public_test.go b/services/agents-api/internal/store/self_hosted_initial_public_test.go index da66eb46f..51bfee0f8 100644 --- a/services/agents-api/internal/store/self_hosted_initial_public_test.go +++ b/services/agents-api/internal/store/self_hosted_initial_public_test.go @@ -203,10 +203,6 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { func publicInitialWorker(t *testing.T, s *store.Store) (*execution.Worker, func(bool)) { t.Helper() dispatcher := &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry()} - dispatcher.EnvironmentConnection = func(context.Context, store.Session, store.Environment) (execution.EnvironmentConnection, error) { - t.Error("offline public creation attempted native preparation") - return execution.EnvironmentConnection{}, errors.New("offline fixture has no native connection") - } worker, err := execution.StartWorker(t.Context(), dispatcher) if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go b/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go deleted file mode 100644 index 5b20d122b..000000000 --- a/services/agents-api/internal/store/self_hosted_public_cancel_native_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package store_test - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestNativePublicSelfHostedCancellationStandalone(t *testing.T) { - f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_cancel_native.py") - // Hold the later native command open while the client retries the old cancel key. - resumedScript := `#!/bin/sh -set -eu -printf 'started\n' >> resumed-start-count -while [ ! -f resumed.release ]; do date +%s > resumed.heartbeat; sleep 1; done -exec ./placement.sh resumed -` - if err := os.WriteFile(filepath.Join(f.local, "resume.sh"), []byte(resumedScript), 0700); err != nil { - t.Fatal(err) - } - daemon := f.startDaemon(t) - awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual long command heartbeat", func() bool { - _, err := os.Stat(filepath.Join(f.local, "cancel.heartbeat")) - return err == nil - }) - f.observeHarnessOwner(t) - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "cancel-ready.json"), map[string]string{"environment_id": f.environmentID}) - requested := awaitPublicNativeSignal(t, f.ctx, f.observer, "cancel-requested", 150*time.Second) - unix, err := strconv.ParseFloat(requested["request_started_unix"], 64) - if err != nil || unix <= 0 { - t.Fatal("missing public cancellation request time") - } - cancelAt := time.UnixMilli(int64(unix * 1000)) - // Measure actual process exit and stopped side effects separately from HTTP and Turn status. - awaitDaemonRemoteExit(t, f.ctx, f.container, f.local) - observedExitSeconds := time.Since(cancelAt).Seconds() - first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-cancelled", 45*time.Second) - if first["turn_id"] != requested["turn_id"] { - t.Fatal("cancelled Turn differs from public request target") - } - binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { - t.Fatal("cancellation lost native device/history binding", err) - } - var receipt *proto.InteractionDecisionAckPayload - firstEvents := f.events(t, first["turn_id"]) - assertDaemonRemoteCommand(t, firstEvents, "first", f.workspace) - for _, event := range firstEvents { - if event.Type != "cancel_receipt" { - continue - } - var value proto.InteractionDecisionAckPayload - if receipt != nil || event.DecodePayload(&value) != nil { - t.Fatal("invalid or repeated native cancellation receipt") - } - receipt = &value - } - if receipt == nil || !receipt.Applied || receipt.Outcome == nil || receipt.ErrorCode != "" || receipt.DeliveryID != "cancel:"+first["turn_id"] || receipt.Outcome.Metadata[proto.DoneMetaAgentSessionID] != binding.NativeSessionID { - t.Fatal("public cancellation lacks its actual native outcome/identity") - } - firstStarts, err := f.nativeStarts() - if err != nil || len(strings.Fields(string(firstStarts))) != 1 { - t.Fatal("cancelled input started another native harness") - } - awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") - select { - case <-daemon.done: - t.Fatal("daemon exited during cancellation") - default: - } - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) - awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual resumed native command", func() bool { - _, err := os.Stat(filepath.Join(f.local, "resumed.heartbeat")) - return err == nil - }) - f.observeHarnessOwner(t) - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resumed-active.json"), map[string]string{"session_id": f.sessionID}) - second := awaitPublicNativeSignal(t, f.ctx, f.observer, "old-cancel-retried", 30*time.Second) - before, err := os.ReadFile(filepath.Join(f.local, "resumed.heartbeat")) - if err != nil { - t.Fatal(err) - } - awaitDaemonRemoteCondition(t, f.ctx, 5*time.Second, "resumed heartbeat after old cancellation replay", func() bool { - after, err := os.ReadFile(filepath.Join(f.local, "resumed.heartbeat")) - return err == nil && string(after) != string(before) - }) - active, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, second["turn_id"]) - if err != nil || active.Status != store.TurnInProgress || !active.CancelRequestedAt.IsZero() { - t.Fatal("old public cancellation affected the active resumed Turn", err) - } - if err := os.WriteFile(filepath.Join(f.local, "resumed.release"), []byte("continue\n"), 0600); err != nil { - t.Fatal(err) - } - select { - case <-f.observer.done: - f.observer.finish(t) - case <-f.ctx.Done(): - t.Fatal("public cancellation continuation timed out; inspect private proof") - } - finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || finalBinding != binding { - t.Fatal("cold continuation changed native device/history binding", err) - } - data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-cancellation-proof.json")) - if err != nil { - t.Fatal(err) - } - var publicProof struct { - Case string `json:"case"` - Turns []struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"turns"` - } - if json.Unmarshal(data, &publicProof) != nil || publicProof.Case != "public_cancellation" || len(publicProof.Turns) != 2 || publicProof.Turns[0].ID != first["turn_id"] || publicProof.Turns[0].Status != "cancelled" || publicProof.Turns[1].ID != second["turn_id"] || publicProof.Turns[1].Status != "completed" { - t.Fatal("public proof lacks cancelled and cold-resumed real Turns") - } - settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) - if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { - t.Fatal("cancellation/retries changed original reservation identity", err) - } - assertDaemonRemoteCommand(t, f.events(t, second["turn_id"]), "resumed", f.workspace) - for _, phase := range []string{"first", "resumed"} { - cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) - if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { - t.Fatal("real command did not use the executor-only workspace") - } - } - for name, want := range map[string]string{"execution-count": "first\nresumed\n", "resumed-start-count": "started\n", "retained.txt": "remote-file-content\n"} { - value, err := os.ReadFile(filepath.Join(f.local, name)) - if err != nil || string(value) != want { - t.Fatal("real cancellation/continuation side effects differ", name, err) - } - } - if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { - t.Fatal("remote workspace appeared on the harness host") - } - if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("native command inherited transport/provider credentials") - } - starts, err := f.nativeStarts() - processes := strings.Fields(string(starts)) - if err != nil || len(processes) != 2 || processes[0] == processes[1] { - t.Fatal("continuation did not start exactly one fresh harness per Turn") - } - artifact, err := os.ReadFile(f.serverBinary) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(artifact) - proof := map[string]any{ - "status": "built_service_public_self_hosted_cancellation_verified", "case": "public_cancellation", - "session_id": f.sessionID, "environment_id": f.environmentID, "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), - "native_version": f.version, "native_thread_id": binding.NativeSessionID, "native_harness_processes": processes, - "cancelled_turn": first["turn_id"], "completed_turn": second["turn_id"], "native_cancel_receipt": receipt, - "cancel_request_to_observed_exit_seconds": observedExitSeconds, "process_exit_and_stopped_heartbeat_observed": true, - "executor_and_daemon_retained": true, "old_cancel_retry_did_not_retarget": true, "command_execution_count": "first\nresumed\n", - "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, - "reservation_id": f.reservation.ID, "reservation_deadline": f.reservation.Deadline, - "public_evidence": filepath.Join(f.observer.directory, "public-cancellation-proof.json"), - "limits": "Observed native cleanup timing is scenario-specific; no general OS quiescence, pre-Start Outcome or complete final usage claim.", - } - f.assertHarnessReleased(t, proof) - persistDaemonRemoteProof(t, f.root, proof, f.secrets) - t.Log("built standalone public cancellation real-provider evidence", f.root) -} diff --git a/services/agents-api/internal/store/self_hosted_public_fixture_test.go b/services/agents-api/internal/store/self_hosted_public_fixture_test.go deleted file mode 100644 index 0a38b412d..000000000 --- a/services/agents-api/internal/store/self_hosted_public_fixture_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package store_test - -import ( - "context" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -type publicSelfHostedFixture struct { - ctx context.Context - store *store.Store - observer *preparedPublicObserver - root, local, workspace, serverBinary, daemonBinary, version string - tenant, sessionID, environmentID, deviceID, remoteURL, container string - reservation store.EnvironmentInputReservation - executor store.IssuedExecutorCredential - daemonEnvironment, secrets []string - harness *publicHarnessProfile -} - -// This fixture provisions credentials and transport only; the Python client owns public Session/input requests. -func newPublicSelfHostedFixture(t *testing.T, mode, script string) *publicSelfHostedFixture { - t.Helper() - if os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") != "" && script != "official_self_hosted_cancel_native.py" { - t.Fatal("public harness qualification currently requires the cancellation fixture") - } - serverBinary, nativeBinary := os.Getenv("PARSAR_AGENTS_API_SERVER_BIN"), os.Getenv("PARSAR_CODEX_BINARY") - image, keyFile := os.Getenv("PARSAR_PLACEMENT_EXECUTOR_IMAGE"), os.Getenv("PARSAR_PLACEMENT_MODEL_KEY_FILE") - proofRoot, daemonBinary := os.Getenv("PARSAR_NATIVE_PROOF_DIR"), os.Getenv("PARSAR_NATIVE_DAEMON_BIN") - if serverBinary == "" || nativeBinary == "" || keyFile == "" || proofRoot == "" || daemonBinary == "" || - !strings.HasPrefix(image, "sha256:") || os.Getenv("PARSAR_EXECUTOR_LAUNCHER") == "" || os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") == "" { - t.Skip("built standalone service, daemon, launcher, pinned native/SDK, local image and real provider credential required") - } - version, err := exec.Command(nativeBinary, "--version").Output() - if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { - t.Fatal("native Codex 0.153.4 required") - } - keyBytes, err := os.ReadFile(keyFile) - if err != nil || strings.TrimSpace(string(keyBytes)) == "" { - t.Fatal("real provider credential unavailable") - } - key := strings.TrimSpace(string(keyBytes)) - s, pool := store.NewTestStore(t) - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) - t.Cleanup(cancel) - root, err := os.MkdirTemp(proofRoot, strings.TrimSuffix(script, ".py")+"-"+mode+"-") - if err != nil { - t.Fatal(err) - } - tenant, foreignTenant := uuid.NewString(), uuid.NewString() - principal := store.FixtureExecutorPrincipal(t, s, tenant) - executor, err := s.IssueExecutorCredential(ctx, principal, uuid.NewString(), "") - if err != nil { - t.Fatal(err) - } - caller, foreign, deviceToken := uuid.NewString(), uuid.NewString(), uuid.NewString() - daemonDevice, err := s.CreateDevice(ctx, tenant, "Public self-hosted acceptance", device.HashCredential(deviceToken)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - cleanup, release := context.WithTimeout(context.Background(), 10*time.Second) - defer release() - if err := s.RevokeDevice(cleanup, tenant, daemonDevice.ID); err != nil { - t.Error("owned device credential cleanup failed", err) - } - if err := s.RevokeExecutorCredential(cleanup, principal, executor.KeyID); err != nil { - t.Error("owned executor credential cleanup failed", err) - } - }) - keys := filepath.Join(root, "api-keys.json") - writePublicNativeJSON(t, keys, []api.APIKey{ - {TenantID: tenant, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(caller)}, - {TenantID: foreignTenant, OrganizationID: principal.OrganizationID, ProjectID: foreignTenant, SubjectKind: principal.SubjectKind, SubjectID: principal.SubjectID, TokenSHA256: device.HashCredential(foreign)}, - }) - address := publicNativeAddress(t) - base := "http://" + address - serverEnvironment := publicNativeEnvironment(map[string]string{ - "AGENTS_API_DATABASE_URL": os.Getenv("PARSAR_AGENTS_API_TEST_DATABASE_URL"), - "AGENTS_API_KEYS_FILE": keys, "AGENTS_API_ADDR": address, "AGENTS_API_ENGINE": "codex", - "AGENTS_API_DAEMON_WS_URL": "ws://" + address + "/api/v1/agent-daemon/ws", - "AGENTS_API_EXECUTOR_URL": base, "AGENTS_API_EXECUTOR_KEYS_FILE": "", "AGENTS_API_HARNESS_KEYS_FILE": "", - "PARSAR_HOME": root, - }) - server := startPublicNativeProcess(t, ctx, root, "server", serverEnvironment, serverBinary) - awaitPublicNativeServer(t, ctx, server, base) - instruction := "REMOTE_" + uuid.NewString() - local := prepareDaemonRemoteWorkspace(t, root, instruction) - workspace := "/parsar-public-self-hosted-" + uuid.NewString() - const memory = "walnut heron violet cedar cobalt willow moss iris" - observer := startPublicNativeClientScript(t, ctx, root, script, map[string]string{ - "base": base, "token": caller, "foreign_token": foreign, "workspace_directory": workspace, - "remote_url": base, "memory": memory, "instruction": instruction, "creation_mode": mode, - "model": os.Getenv("PARSAR_PLACEMENT_MODEL"), - }) - waiting := awaitPublicNativeSignal(t, ctx, observer, "waiting", 70*time.Second) - sessionID, environmentID := waiting["session_id"], waiting["environment_id"] - environment, err := s.GetSessionEnvironment(ctx, tenant, sessionID) - if err != nil || environment.ID != environmentID || waiting["remote_url"] != base || environment.Status != "pending" || waiting["creation_mode"] != mode { - t.Fatal("public Environment differs from owned offline target", err) - } - var reservationID string - if err := pool.QueryRow(ctx, "SELECT id FROM environment_input_reservations WHERE session_id=$1", sessionID).Scan(&reservationID); err != nil { - t.Fatal("public first input did not retain its reservation", err) - } - reservation, err := s.GetEnvironmentInputReservation(ctx, tenant, sessionID, reservationID) - if err != nil || reservation.State != store.EnvironmentInputPending || reservation.IsInitial != (mode != "empty_later") { - t.Fatal("public first input has incorrect reservation origin/state", err) - } - container := startDaemonRemoteExecutor(t, ctx, root, local, workspace, nativeBinary, image, waiting["remote_url"], environmentID, executor) - awaitEnvironmentConnectionState(t, ctx, s, tenant, environmentID, "connected") - writePublicNativeJSON(t, filepath.Join(observer.directory, "initial-connection-ready.json"), map[string]string{"environment_id": environmentID}) - connectedRead := awaitPublicNativeSignal(t, ctx, observer, "initial-connection-read", 20*time.Second) - if connectedRead["environment_id"] != environmentID { - t.Fatal("public connected retrieval observed the wrong Environment") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v1/agents/environments/"+environmentID, nil) - if err != nil { - t.Fatal(err) - } - request.Header.Set("Authorization", "Bearer "+executor.Token) - request.Header.Set("OpenAI-Beta", "agents=v1") - client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{Proxy: nil}} - defer client.CloseIdleConnections() - response, err := client.Do(request) - if err != nil { - t.Fatal("executor-only credential retrieval request failed", err) - } - _ = response.Body.Close() - if response.StatusCode != http.StatusUnauthorized { - t.Fatal("executor-only credential authorized a public Environment read") - } - profile := filepath.Join(root, "parsar-daemon", "execution") - if err := os.MkdirAll(profile, 0700); err != nil { - t.Fatal(err) - } - writePublicNativeJSON(t, filepath.Join(profile, "auth.json"), map[string]string{ - "server_url": base + "/api/v1", "runtime_id": daemonDevice.ID, "runner_credential": deviceToken, "device_name": "Public self-hosted acceptance", - }) - wrapper := filepath.Join(root, "codex-minimax") - wrapperText := "#!/bin/sh\nfor argument in \"$@\"; do\n if [ \"$argument\" = app-server ]; then printf '%s\\n' \"$$\" >> \"$PARSAR_PUBLIC_NATIVE_LAUNCH_LOG\"; fi\ndone\nexec \"$PARSAR_PUBLIC_NATIVE_CODEX\" -c 'model_provider=\"minimax_validation\"' -c 'model_providers.minimax_validation.name=\"MiniMax validation\"' -c 'model_providers.minimax_validation.base_url=\"https://api.minimax.cn/v1\"' -c 'model_providers.minimax_validation.env_key=\"MINIMAX_VALIDATION_KEY\"' -c 'model_providers.minimax_validation.wire_api=\"responses\"' \"$@\"\n" - if os.Getenv("PARSAR_PUBLIC_HARNESS_ARTIFACT") == "" { - if err := os.WriteFile(wrapper, []byte(wrapperText), 0700); err != nil { - t.Fatal(err) - } - } - daemonEnvironment := publicNativeEnvironment(map[string]string{ - "PARSAR_HOME": root, "PARSAR_CODEX_BIN": wrapper, "PARSAR_PUBLIC_NATIVE_CODEX": nativeBinary, "MINIMAX_VALIDATION_KEY": key, - "PARSAR_PUBLIC_NATIVE_LAUNCH_LOG": filepath.Join(root, "native-starts"), - }) - fixture := &publicSelfHostedFixture{ - ctx: ctx, store: s, observer: observer, root: root, local: local, workspace: workspace, - serverBinary: serverBinary, daemonBinary: daemonBinary, version: strings.TrimSpace(string(version)), - tenant: tenant, sessionID: sessionID, environmentID: environmentID, deviceID: daemonDevice.ID, - remoteURL: waiting["remote_url"], container: container, reservation: reservation, executor: executor, - daemonEnvironment: daemonEnvironment, secrets: []string{key, caller, foreign, deviceToken, executor.Token}, - } - fixture.harness = newPublicHarnessProfile(t, fixture, nativeBinary, image, key) - return fixture -} - -func (f *publicSelfHostedFixture) startDaemon(t *testing.T) *relayProcess { - t.Helper() - if f.harness != nil { - return f.harness.start(t, f) - } - return startPublicNativeProcess(t, f.ctx, f.root, "daemon", f.daemonEnvironment, f.daemonBinary, "connect", "--profile", "execution") -} - -func (f *publicSelfHostedFixture) events(t *testing.T, turnID string) []proto.Envelope { - t.Helper() - var observed []proto.Envelope - var after int32 - for { - events, err := f.store.ListTurnEvents(f.ctx, f.tenant, f.sessionID, turnID, after, 100) - if err != nil { - t.Fatal(err) - } - if len(events) == 0 { - return observed - } - for _, event := range events { - observed = append(observed, proto.Envelope{Type: event.Kind, Payload: event.Payload}) - after = event.Ordinal - } - } -} diff --git a/services/agents-api/internal/store/self_hosted_public_functions_native_test.go b/services/agents-api/internal/store/self_hosted_public_functions_native_test.go deleted file mode 100644 index 8335457fe..000000000 --- a/services/agents-api/internal/store/self_hosted_public_functions_native_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package store_test - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestNativePublicSelfHostedFunctionsStandalone(t *testing.T) { - f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_functions_native.py") - f.startDaemon(t) - first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 180*time.Second) - binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { - t.Fatal("first function Turn lacks native device/history binding", err) - } - call, err := f.store.GetFunctionCall(f.ctx, f.tenant, f.sessionID, first["turn_id"], first["call_id"]) - if err != nil || !call.Applied || len(call.Result) == 0 { - t.Fatal("first public function result lacks a native application receipt", err) - } - firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - if err != nil || len(strings.Fields(string(firstStarts))) != 1 { - t.Fatal("first function Turn started more than one native harness") - } - awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) - select { - case <-f.observer.done: - f.observer.finish(t) - case <-f.ctx.Done(): - t.Fatal("public function continuation timed out; inspect private proof") - } - finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || finalBinding != binding { - t.Fatal("function continuation changed native history/device binding", err) - } - data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-functions-proof.json")) - if err != nil { - t.Fatal(err) - } - var publicProof struct { - Case string `json:"case"` - Status string `json:"status"` - Turns []struct { - ID string `json:"id"` - } `json:"turns"` - Calls []string `json:"calls"` - Submissions []struct { - Event map[string]any `json:"event"` - } `json:"accepted_results"` - } - if json.Unmarshal(data, &publicProof) != nil || publicProof.Case != "public_functions" || publicProof.Status != "public_functions_and_cold_continuation_verified" || len(publicProof.Turns) != 2 || len(publicProof.Calls) != 2 || len(publicProof.Submissions) != 2 || publicProof.Turns[0].ID != first["turn_id"] { - t.Fatal("public proof does not contain the two accepted function Turns") - } - settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) - if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { - t.Fatal("function results changed the original message reservation", err) - } - receipts := make([]store.FunctionCall, 0, 2) - for index, phase := range []string{"first", "resumed"} { - turnID := publicProof.Turns[index].ID - call, err := f.store.GetFunctionCall(f.ctx, f.tenant, f.sessionID, turnID, publicProof.Calls[index]) - if err != nil || !call.Applied || call.ExecutorCallID == "" || call.Name != "lookup_festival" { - t.Fatal("public function result was not acknowledged by the native adapter", err) - } - var arguments, result map[string]any - if json.Unmarshal(call.Arguments, &arguments) != nil || !reflect.DeepEqual(arguments, map[string]any{"phase": phase}) || json.Unmarshal(call.Result, &result) != nil { - t.Fatal("stored function arguments/result differ from the public call") - } - expected := publicProof.Submissions[index].Event - for _, field := range []string{"type", "turn_id", "call_id"} { - delete(expected, field) - } - if !reflect.DeepEqual(result, expected) { - t.Fatal("stored function result did not preserve the SDK submission") - } - receipts = append(receipts, call) - assertDaemonRemoteCommand(t, f.events(t, turnID), phase, f.workspace) - cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) - if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { - t.Fatal("function Turn command did not run in the executor-only workspace") - } - } - count, err := os.ReadFile(filepath.Join(f.local, "execution-count")) - if err != nil || string(count) != "first\nresumed\n" { - t.Fatal("function or input retry repeated or omitted real command execution") - } - retained, err := os.ReadFile(filepath.Join(f.local, "retained.txt")) - if err != nil || string(retained) != "remote-file-content\n" { - t.Fatal("remote file did not persist between function Turns") - } - if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { - t.Fatal("remote workspace appeared on the harness host") - } - if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("native command inherited transport/provider credentials") - } - starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - processes := strings.Fields(string(starts)) - if err != nil || len(processes) != 2 || processes[0] == processes[1] { - t.Fatal("function continuation did not cold-start exactly one fresh harness per Turn") - } - artifact, err := os.ReadFile(f.serverBinary) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(artifact) - proof := map[string]any{ - "status": "built_service_public_self_hosted_functions_verified", "case": "public_functions", "completed_turns": 2, - "session_id": f.sessionID, "environment_id": f.environmentID, "native_thread_id": binding.NativeSessionID, - "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_version": f.version, - "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, - "native_harness_processes": processes, "native_function_receipts": receipts, "command_execution_count": string(count), - "old_result_retry_did_not_retarget": true, "public_evidence": filepath.Join(f.observer.directory, "public-functions-proof.json"), - "limits": "One success mapping and one SDK-generated error through real native callbacks; no complete tool-set, image understanding or crash recovery claim.", - } - persistDaemonRemoteProof(t, f.root, proof, f.secrets) - t.Log("built standalone public self-hosted function real-provider evidence", f.root) -} diff --git a/services/agents-api/internal/store/self_hosted_public_helpers_test.go b/services/agents-api/internal/store/self_hosted_public_helpers_test.go deleted file mode 100644 index 6ae376459..000000000 --- a/services/agents-api/internal/store/self_hosted_public_helpers_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -func startPublicNativeProcess(t *testing.T, ctx context.Context, root, name string, environment []string, binary string, args ...string) *relayProcess { - t.Helper() - output, err := os.OpenFile(filepath.Join(root, name+".log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - t.Fatal(err) - } - command := exec.CommandContext(ctx, binary, args...) - command.Env, command.Dir = environment, root - command.Stdout, command.Stderr = output, output - if err := command.Start(); err != nil { - _ = output.Close() - t.Fatal(name, "failed to start", err) - } - process := &relayProcess{command: command, done: make(chan struct{})} - go func() { process.err = command.Wait(); _ = output.Close(); close(process.done) }() - t.Cleanup(func() { - _ = command.Process.Signal(os.Interrupt) - select { - case <-process.done: - case <-time.After(12 * time.Second): - _ = command.Process.Kill() - <-process.done - } - }) - return process -} - -func publicNativeAddress(t *testing.T) string { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - address := listener.Addr().String() - if err := listener.Close(); err != nil { - t.Fatal(err) - } - return address -} - -func awaitPublicNativeServer(t *testing.T, ctx context.Context, process *relayProcess, base string) { - t.Helper() - client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} - defer client.CloseIdleConnections() - awaitDaemonRemoteCondition(t, ctx, 20*time.Second, "built Agents API health", func() bool { - select { - case <-process.done: - t.Fatal("built Agents API exited; inspect private server log") - default: - } - response, err := client.Get(base + "/healthz") - if err != nil { - return false - } - _ = response.Body.Close() - return response.StatusCode == http.StatusOK - }) -} - -func writePublicNativeJSON(t *testing.T, path string, value any) { - t.Helper() - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } -} - -func startPublicNativeClientScript(t *testing.T, ctx context.Context, root, script string, settings map[string]string) *preparedPublicObserver { - t.Helper() - directory := filepath.Join(root, "public-environment") - if err := os.MkdirAll(directory, 0700); err != nil { - t.Fatal(err) - } - settings["evidence"] = directory - input, err := json.Marshal(settings) - if err != nil { - t.Fatal(err) - } - output, err := os.OpenFile(filepath.Join(directory, "observer.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - t.Fatal(err) - } - owner, cancel := context.WithCancel(ctx) - command := exec.CommandContext(owner, os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), filepath.Join("../../tests", script)) - command.Stdin, command.Stdout, command.Stderr = bytes.NewReader(input), output, output - if err := command.Start(); err != nil { - cancel() - _ = output.Close() - t.Fatal(err) - } - observer := &preparedPublicObserver{directory: directory, command: command, cancel: cancel, done: make(chan struct{})} - go func() { observer.err = command.Wait(); _ = output.Close(); close(observer.done) }() - t.Cleanup(observer.close) - return observer -} - -func awaitPublicNativeSignal(t *testing.T, ctx context.Context, observer *preparedPublicObserver, name string, timeout time.Duration) map[string]string { - t.Helper() - var value map[string]string - awaitDaemonRemoteCondition(t, ctx, timeout, "public client "+name, func() bool { - select { - case <-observer.done: - t.Fatal("public client exited before signal; inspect private client log", observer.err) - default: - } - data, err := os.ReadFile(filepath.Join(observer.directory, name+".json")) - if os.IsNotExist(err) { - return false - } - if err != nil || json.Unmarshal(data, &value) != nil { - t.Fatal("invalid public client signal") - } - return true - }) - return value -} - -func publicNativeEnvironment(overrides map[string]string) []string { - result := make([]string, 0, len(os.Environ())+len(overrides)) - for _, entry := range os.Environ() { - name, _, _ := strings.Cut(entry, "=") - if _, replaced := overrides[name]; !replaced { - result = append(result, entry) - } - } - for name, value := range overrides { - result = append(result, name+"="+value) - } - return result -} diff --git a/services/agents-api/internal/store/self_hosted_public_native_test.go b/services/agents-api/internal/store/self_hosted_public_native_test.go deleted file mode 100644 index b4f950e50..000000000 --- a/services/agents-api/internal/store/self_hosted_public_native_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package store_test - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestNativePublicSelfHostedStandalone(t *testing.T) { - runNativePublicSelfHosted(t, "empty_later") -} - -func TestNativePublicSelfHostedOrdinaryInitialStandalone(t *testing.T) { - runNativePublicSelfHosted(t, "ordinary_initial") -} - -func TestNativePublicSelfHostedStreamedInitialStandalone(t *testing.T) { - runNativePublicSelfHosted(t, "streamed_initial") -} - -func runNativePublicSelfHosted(t *testing.T, mode string) { - t.Helper() - f := newPublicSelfHostedFixture(t, mode, "official_self_hosted.py") - f.startDaemon(t) - first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 180*time.Second) - binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { - t.Fatal("first public Turn lacks native device/history binding", err) - } - firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - if err != nil || len(strings.Fields(string(firstStarts))) != 1 { - t.Fatal("first input retry started another native harness") - } - awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) - select { - case <-f.observer.done: - f.observer.finish(t) - case <-f.ctx.Done(): - t.Fatal("public second Turn timed out; inspect private proof") - } - finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || finalBinding != binding { - t.Fatal("public continuation changed native history/device binding", err) - } - data, err := os.ReadFile(filepath.Join(f.observer.directory, "public-environment-proof.json")) - if err != nil { - t.Fatal(err) - } - var publicProof struct { - CreationMode string `json:"creation_mode"` - Turns []struct { - ID string `json:"id"` - } `json:"turns"` - } - if json.Unmarshal(data, &publicProof) != nil || publicProof.CreationMode != mode || len(publicProof.Turns) != 2 || publicProof.Turns[0].ID != first["turn_id"] { - t.Fatal("public proof does not contain the two accepted Turns") - } - settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) - if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial != f.reservation.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { - t.Fatal("public retries changed original reservation origin/deadline", err) - } - for index, phase := range []string{"first", "resumed"} { - observed := f.events(t, publicProof.Turns[index].ID) - assertDaemonRemoteCommand(t, observed, phase, f.workspace) - cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) - if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { - t.Fatal("actual command did not run in the executor-only workspace") - } - } - count, err := os.ReadFile(filepath.Join(f.local, "execution-count")) - if err != nil || string(count) != "first\nresumed\n" { - t.Fatal("public retry repeated or omitted real command execution") - } - retained, err := os.ReadFile(filepath.Join(f.local, "retained.txt")) - if err != nil || string(retained) != "remote-file-content\n" { - t.Fatal("remote file did not persist between public Turns") - } - if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { - t.Fatal("remote workspace appeared on the harness host") - } - if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("native command inherited transport/provider credentials") - } - starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - processes := strings.Fields(string(starts)) - if err != nil || len(processes) != 2 || processes[0] == processes[1] { - t.Fatal("continuation did not cold-start exactly one fresh harness per Turn") - } - artifact, err := os.ReadFile(f.serverBinary) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(artifact) - proof := map[string]any{ - "status": "built_service_public_self_hosted_real_execution_verified", "session_id": f.sessionID, "environment_id": f.environmentID, - "creation_mode": mode, "reservation_id": f.reservation.ID, "reservation_initial": f.reservation.IsInitial, "reservation_deadline": f.reservation.Deadline, - "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_thread_id": binding.NativeSessionID, - "native_version": f.version, "completed_turns": 2, "launcher_remote_url": f.remoteURL, - "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, "executor_key_issued_before_session": true, - "already_connected_second_input": true, "command_execution_count": string(count), "public_evidence": filepath.Join(f.observer.directory, "public-environment-proof.json"), - "native_harness_processes": processes, - } - persistDaemonRemoteProof(t, f.root, proof, f.secrets) - t.Log("built standalone public self-hosted real-provider evidence", f.root) -} diff --git a/services/agents-api/internal/store/self_hosted_public_steering_native_test.go b/services/agents-api/internal/store/self_hosted_public_steering_native_test.go deleted file mode 100644 index b1f3179a8..000000000 --- a/services/agents-api/internal/store/self_hosted_public_steering_native_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package store_test - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestNativePublicSelfHostedSteeringStandalone(t *testing.T) { - f := newPublicSelfHostedFixture(t, "empty_later", "official_self_hosted_steering_native.py") - const gate = `#!/bin/sh -set -eu -phase="$1" -printf '%s\n' "$phase" >> gate-start-count -remaining=90 -while [ ! -f "$phase.release" ]; do - test "$remaining" -gt 0 || { printf 'fixture gate timed out\n' >&2; exit 94; } - date +%s > "$phase.heartbeat" - remaining=$((remaining - 1)) - sleep 1 -done -exec ./placement.sh "$phase" -` - if err := os.WriteFile(filepath.Join(f.local, "gate.sh"), []byte(gate), 0700); err != nil { - t.Fatal(err) - } - f.startDaemon(t) - t.Cleanup(func() { - for _, phase := range []string{"first", "resumed"} { - _ = os.WriteFile(filepath.Join(f.local, phase+".release"), []byte("cleanup\n"), 0600) - } - }) - awaitHeartbeat := func(phase string) { - t.Helper() - awaitDaemonRemoteCondition(t, f.ctx, 150*time.Second, "actual "+phase+" command heartbeat", func() bool { - select { - case <-f.observer.done: - t.Fatal("public steering client exited before native gate; inspect private proof") - default: - } - _, err := os.Stat(filepath.Join(f.local, phase+".heartbeat")) - return err == nil - }) - } - releaseGate := func(phase string) { - t.Helper() - if err := os.WriteFile(filepath.Join(f.local, phase+".release"), []byte("continue\n"), 0600); err != nil { - t.Fatal(err) - } - } - awaitHeartbeat("first") - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "steer-ready.json"), map[string]string{"environment_id": f.environmentID}) - submitted := awaitPublicNativeSignal(t, f.ctx, f.observer, "steer-submitted", 30*time.Second) - firstID := submitted["turn_id"] - inputs, err := f.store.ListTurnInputs(f.ctx, f.tenant, f.sessionID, firstID, 0, 100) - if err != nil || len(inputs) != 2 || inputs[1].Kind != "message" || submitted["value"] == "" || !strings.Contains(string(inputs[1].Payload), submitted["value"]) { - t.Fatal("active public input did not target the original Turn exactly once", err) - } - sequence := inputs[1].Sequence - var releaseReceipt proto.PromptSteerAckPayload - awaitDaemonRemoteCondition(t, f.ctx, 30*time.Second, "actual native steering write", func() bool { - for _, event := range f.events(t, firstID) { - if event.Type == proto.TypePromptSteerAck { - var receipt proto.PromptSteerAckPayload - if event.DecodePayload(&receipt) != nil { - t.Fatal("invalid native steering receipt") - } - if receipt.InputID == strconv.FormatInt(sequence, 10) && (receipt.Written || receipt.Accepted) { - releaseReceipt = receipt - return true - } - } - } - return false - }) - // Written releases this test gate only; final acceptance below requires native Accepted. - releaseGate("first") - first := awaitPublicNativeSignal(t, f.ctx, f.observer, "first-completed", 150*time.Second) - if first["turn_id"] != firstID { - t.Fatal("steering completed a different public Turn") - } - completed, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, firstID) - var outcome execution.Result - if err != nil || completed.Status != store.TurnCompleted || json.Unmarshal(completed.Outcome, &outcome) != nil || outcome.AppliedThrough != sequence { - t.Fatal("completed Turn did not retain native steering application", err) - } - var applied []proto.PromptSteerAckPayload - for _, event := range f.events(t, firstID) { - if event.Type == proto.TypePromptSteerAck { - var receipt proto.PromptSteerAckPayload - if event.DecodePayload(&receipt) != nil || receipt.InputID != strconv.FormatInt(sequence, 10) { - t.Fatal("native steering receipt has the wrong input identity") - } - if receipt.Accepted { - applied = append(applied, receipt) - } - } - } - if len(applied) != 1 || applied[0].ErrorCode != "" { - t.Fatal("active input lacks exactly one actual native acceptance receipt") - } - binding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || binding.Device.ID != f.deviceID || binding.NativeSessionID == "" { - t.Fatal("steered Turn lacks native device/history binding", err) - } - firstStarts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - if err != nil || len(strings.Fields(string(firstStarts))) != 1 { - t.Fatal("steering started another harness instead of using the active one") - } - awaitEnvironmentConnectionState(t, f.ctx, f.store, f.tenant, f.environmentID, "connected") - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resume-ready.json"), map[string]string{"session_id": f.sessionID}) - awaitHeartbeat("resumed") - writePublicNativeJSON(t, filepath.Join(f.observer.directory, "resumed-active.json"), map[string]string{"session_id": f.sessionID}) - second := awaitPublicNativeSignal(t, f.ctx, f.observer, "old-steer-retried", 30*time.Second) - secondID := second["turn_id"] - active, err := f.store.GetTurn(f.ctx, f.tenant, f.sessionID, secondID) - if err != nil || active.Status != store.TurnInProgress || secondID == firstID { - t.Fatal("old active-input retry changed later execution", err) - } - secondInputs, err := f.store.ListTurnInputs(f.ctx, f.tenant, f.sessionID, secondID, 0, 100) - if err != nil || len(secondInputs) != 1 || strings.Contains(string(secondInputs[0].Payload), submitted["value"]) { - t.Fatal("old active input was replayed into the cold Turn", err) - } - releaseGate("resumed") - select { - case <-f.observer.done: - f.observer.finish(t) - case <-f.ctx.Done(): - t.Fatal("public steering continuation timed out; inspect private proof") - } - finalBinding, err := f.store.GetSessionExecutionBinding(f.ctx, f.tenant, f.sessionID) - if err != nil || finalBinding != binding { - t.Fatal("cold steering continuation changed native history binding", err) - } - for _, event := range f.events(t, secondID) { - if event.Type == proto.TypePromptSteerAck { - t.Fatal("old active-input retry reached the later native Turn") - } - } - settled, err := f.store.GetEnvironmentInputReservation(f.ctx, f.tenant, f.sessionID, f.reservation.ID) - if err != nil || settled.State != store.EnvironmentInputAdmitted || settled.IsInitial || !settled.Deadline.Equal(f.reservation.Deadline) { - t.Fatal("active inputs changed the original reservation", err) - } - for index, phase := range []string{"first", "resumed"} { - assertDaemonRemoteCommand(t, f.events(t, []string{firstID, secondID}[index]), phase, f.workspace) - cwd, err := os.ReadFile(filepath.Join(f.local, phase+".cwd")) - if err != nil || strings.TrimSpace(string(cwd)) != f.workspace { - t.Fatal("real command did not use the executor-only workspace") - } - } - for name, want := range map[string]string{"execution-count": "first\nresumed\n", "gate-start-count": "first\nresumed\n", "retained.txt": "remote-file-content\n"} { - value, err := os.ReadFile(filepath.Join(f.local, name)) - if err != nil || string(value) != want { - t.Fatal("real steering/continuation side effects differ", name, err) - } - } - if _, err := os.Stat(f.workspace); !os.IsNotExist(err) { - t.Fatal("remote workspace appeared on the harness host") - } - if _, err := os.Stat(filepath.Join(f.local, "credential-failure")); !os.IsNotExist(err) { - t.Fatal("native command inherited transport/provider credentials") - } - starts, err := os.ReadFile(filepath.Join(f.root, "native-starts")) - processes := strings.Fields(string(starts)) - if err != nil || len(processes) != 2 || processes[0] == processes[1] { - t.Fatal("steering continuation did not use exactly one fresh harness per Turn") - } - artifact, err := os.ReadFile(f.serverBinary) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(artifact) - proof := map[string]any{ - "status": "built_service_public_self_hosted_steering_verified", "case": "public_steering", "completed_turns": 2, - "session_id": f.sessionID, "environment_id": f.environmentID, "native_thread_id": binding.NativeSessionID, - "server_binary": f.serverBinary, "server_sha256": hex.EncodeToString(digest[:]), "native_version": f.version, - "launcher_remote_url": f.remoteURL, "launcher_environment_id": f.environmentID, "executor_key_id": f.executor.KeyID, - "native_harness_processes": processes, "gate_release_receipt": releaseReceipt, "native_accepted_receipts": applied, - "steered_turn_id": firstID, "input_sequence": sequence, "applied_through": outcome.AppliedThrough, - "old_input_did_not_retarget": true, "command_execution_count": "first\nresumed\n", - "public_evidence": filepath.Join(f.observer.directory, "public-steering-proof.json"), - "limits": "Written only releases the fixture gate. Accepted, applied cursor and model answers establish this workflow; no general crash recovery or OS-quiescence claim.", - } - persistDaemonRemoteProof(t, f.root, proof, f.secrets) - t.Log("built standalone public self-hosted steering real-provider evidence", f.root) -} diff --git a/services/agents-api/internal/store/self_hosted_steering_public_test.go b/services/agents-api/internal/store/self_hosted_steering_public_test.go deleted file mode 100644 index 6b3ae9c60..000000000 --- a/services/agents-api/internal/store/self_hosted_steering_public_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package store_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "reflect" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" -) - -func TestSelfHostedSteeringOfficialClient(t *testing.T) { - python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") - if python == "" { - t.Skip("pinned official Python SDK required") - } - s, pool := store.NewTestStore(t) - tenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() - auth, err := api.NewAuthenticator([]api.APIKey{ - {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "steering-caller", TokenSHA256: device.HashCredential(token), TenantID: tenant}, - {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "steering-caller", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - if err != nil { - t.Fatal(err) - } - worker, _ := publicInitialWorker(t, s) - handler, err := api.NewHandler(s, auth, "codex", api.WithExecution(worker), api.WithEnvironmentRemoteURL("https://offline-executor.example")) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - settings := map[string]any{"base": server.URL, "token": token, "foreign_token": foreign} - run := func(phase string) json.RawMessage { - t.Helper() - settings["phase"] = phase - input, err := json.Marshal(settings) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) - defer cancel() - command := exec.CommandContext(ctx, python, "../../tests/official_self_hosted_steering.py") - command.Stdin = bytes.NewReader(input) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("public self-hosted steering %s: %v %s", phase, err, output) - } - if !json.Valid(output) { - t.Fatal("invalid public steering fixture result") - } - return output - } - accepted := run("create") - var created struct { - ID string `json:"id"` - InitialID string `json:"initial_id"` - LaterID string `json:"later_id"` - BatchKey string `json:"batch_key"` - PendingKey string `json:"pending_key"` - IdleKey string `json:"idle_key"` - RollbackKey string `json:"rollback_key"` - Batch []json.RawMessage `json:"batch"` - PendingEvent json.RawMessage `json:"pending_event"` - } - if err := json.Unmarshal(accepted, &created); err != nil || created.ID == "" || len(created.Batch) != 2 { - t.Fatal("missing public steering fixture identities", err) - } - settings["accepted"] = accepted - inputs := []store.Input{{Kind: "message", Payload: created.Batch[0]}, {Kind: "message", Payload: created.Batch[1]}} - if _, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, created.PendingKey, []store.Input{{Kind: "message", Payload: created.PendingEvent}}); err != nil { - t.Fatal(err) - } - snapshot := func(sessionID string) string { - t.Helper() - var value string - err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object( - 'session', (SELECT to_jsonb(s) FROM sessions s WHERE id=$1), - 'reservations', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM environment_input_reservations r WHERE session_id=$1), - 'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM turns t WHERE session_id=$1), - 'inputs', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.sequence) FROM turn_inputs i WHERE session_id=$1), - 'items', (SELECT jsonb_agg(to_jsonb(i) ORDER BY i.id) FROM session_items i WHERE session_id=$1), - 'events', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM session_events e WHERE session_id=$1))::text`, sessionID).Scan(&value) - if err != nil { - t.Fatal(err) - } - return value - } - pending := map[string]string{created.InitialID: snapshot(created.InitialID), created.LaterID: snapshot(created.LaterID)} - transition := func(turn, from, to string) { - t.Helper() - if _, err := s.TransitionTurn(t.Context(), tenant, created.ID, turn, store.TurnTransition{ExpectedStatus: from, Status: to}); err != nil { - t.Fatal(err) - } - } - start := func() string { - t.Helper() - // Controlled Turn callbacks isolate admission from native application and model behavior. - input, err := s.SubmitMessage(t.Context(), tenant, created.ID, uuid.NewString(), json.RawMessage(`{"text":"Controlled original work."}`)) - if err != nil { - t.Fatal(err) - } - transition(input.TurnID, store.TurnQueued, store.TurnInProgress) - settings["turn_id"] = input.TurnID - return input.TurnID - } - first := start() - run("active") - direct, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.ID, created.BatchKey, inputs) - if err != nil || direct.State != store.EnvironmentInputAdmitted || direct.ID != "" || !direct.Deadline.IsZero() || len(direct.Receipts) != 2 || direct.Receipts[0].TurnID != first || !direct.Receipts[0].Replayed { - t.Fatal("active batch acquired a reservation or changed its direct receipt", direct, err) - } - history, err := s.ListTurnInputs(t.Context(), tenant, created.ID, first, 0, 100) - if err != nil || len(history) != 3 || history[1].Sequence != direct.Receipts[0].Sequence || history[2].Sequence != direct.Receipts[1].Sequence { - t.Fatal("concurrent active batch duplicated or reordered inputs", err) - } - var reservations int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", created.ID).Scan(&reservations); err != nil || reservations != 0 { - t.Fatal("active text created a preparation reservation", err) - } - before := snapshot(created.ID) - run("reject") - if snapshot(created.ID) != before { - t.Fatal("rejected steering changed history or retry identity") - } - constraint := "steering_failure_" + strings.ReplaceAll(uuid.NewString(), "-", "") - if _, err := pool.Exec(t.Context(), "ALTER TABLE turn_inputs ADD CONSTRAINT "+constraint+" CHECK (idempotency_key <> '"+created.RollbackKey+"' OR batch_position=0)"); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _, _ = pool.Exec(context.Background(), "ALTER TABLE turn_inputs DROP CONSTRAINT IF EXISTS "+constraint) - }) - run("rollback") - if snapshot(created.ID) != before { - t.Fatal("second-insert failure left partial text, Items or events") - } - if _, err := pool.Exec(t.Context(), "ALTER TABLE turn_inputs DROP CONSTRAINT "+constraint); err != nil { - t.Fatal(err) - } - transition(first, store.TurnInProgress, store.TurnCompleted) - for _, phase := range []string{"terminal", "later"} { - if phase == "later" { - start() - } - before := snapshot(created.ID) - run(phase) - retry, err := s.ReserveEnvironmentInput(t.Context(), tenant, created.ID, created.BatchKey, inputs) - if err != nil || !reflect.DeepEqual(direct, retry) || snapshot(created.ID) != before { - t.Fatal("active text retry retargeted work or changed its direct identity", err) - } - } - transition(settings["turn_id"].(string), store.TurnInProgress, store.TurnCompleted) - run("idle") - var id string - if err := pool.QueryRow(t.Context(), "SELECT id FROM environment_input_reservations WHERE session_id=$1 AND idempotency_key=$2", created.ID, created.IdleKey).Scan(&id); err != nil { - t.Fatal("idle input did not wait for preparation", err) - } - idle, err := s.GetEnvironmentInputReservation(t.Context(), tenant, created.ID, id) - if err != nil || idle.State != store.EnvironmentInputPending || len(idle.Receipts) != 0 || idle.Deadline.Sub(idle.CreatedAt) != 5*time.Minute { - t.Fatal("idle input bypassed preparation", idle, err) - } - for id, expected := range pending { - if snapshot(id) != expected { - t.Fatal("steering changed pending input, deadline or foreign history") - } - } -} diff --git a/services/agents-api/internal/store/session_artifacts_test.go b/services/agents-api/internal/store/session_artifacts_test.go index 2884295de..26f4ebe57 100644 --- a/services/agents-api/internal/store/session_artifacts_test.go +++ b/services/agents-api/internal/store/session_artifacts_test.go @@ -48,8 +48,14 @@ func artifactTurn(t *testing.T, s *Store, kind string) (tenant, session, environ } func TestSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T) { + for _, kind := range []string{"openai_hosted", "self_hosted"} { + t.Run(kind, func(t *testing.T) { testSessionArtifactsPublishVersionScopeAndLifetime(t, kind) }) + } +} + +func testSessionArtifactsPublishVersionScopeAndLifetime(t *testing.T, kind string) { s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + tenant, session, environment, turn := artifactTurn(t, s, kind) before := sourceObjectCount(t, pool) data := bytes.Repeat([]byte("immutable\x00"), 100000) archive := artifactArchive(t, map[string][]byte{"outputs/a.bin": data, "outputs/nested/empty": {}}) @@ -166,8 +172,14 @@ type artifactReadError struct{} func (artifactReadError) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } func TestSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T) { + for _, kind := range []string{"openai_hosted", "self_hosted"} { + t.Run(kind, func(t *testing.T) { testSessionArtifactsRejectIncompleteAndUnownedCapture(t, kind) }) + } +} + +func testSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T, kind string) { s, pool := testStore(t) - tenant, session, environment, turn := artifactTurn(t, s, "openai_hosted") + tenant, session, environment, turn := artifactTurn(t, s, kind) before := sourceObjectCount(t, pool) valid := artifactArchive(t, map[string][]byte{"outputs/a": []byte("data")}) for name, body := range map[string]io.Reader{ @@ -191,10 +203,6 @@ func TestSessionArtifactsRejectIncompleteAndUnownedCapture(t *testing.T) { t.Fatalf("unauthorized capture reached reader: %v", err) } } - st, ss, se, sr := artifactTurn(t, s, "self_hosted") - if err := s.StageTurnArtifacts(t.Context(), st, ss, sr, se, artifactReadError{}); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("self_hosted publication allowed: %v", err) - } } func TestSessionArtifactsDiscardTerminalPrivateCapture(t *testing.T) { diff --git a/services/agents-api/migrations/000050_runtime_enrollment.sql b/services/agents-api/migrations/000050_runtime_enrollment.sql new file mode 100644 index 000000000..b263e5d79 --- /dev/null +++ b/services/agents-api/migrations/000050_runtime_enrollment.sql @@ -0,0 +1,40 @@ +-- +goose Up +ALTER TABLE devices + ADD COLUMN executor_key_id uuid REFERENCES environment_executor_credentials(key_id), + ALTER COLUMN credential_hash DROP NOT NULL, + ADD CONSTRAINT device_credential_source CHECK ( + (executor_key_id IS NULL AND credential_hash IS NOT NULL) + OR (executor_key_id IS NOT NULL AND credential_hash IS NULL AND environment_id IS NOT NULL) + ); + +-- Resolve the current credential once for bootstrap, reconnect and heartbeat. +CREATE VIEW runtime_device_authority AS +SELECT d.id, d.tenant_id, d.name, d.environment_id, + COALESCE(c.token_sha256, d.credential_hash) AS credential_hash +FROM devices d +LEFT JOIN environments e ON e.id = d.environment_id +LEFT JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +LEFT JOIN environment_executor_credentials c ON c.key_id = d.executor_key_id +WHERE d.revoked_at IS NULL + AND (d.environment_id IS NULL OR (s.id IS NOT NULL AND s.deleted_at IS NULL)) + AND (d.executor_key_id IS NULL OR ( + c.revoked_at IS NULL AND c.tenant_id = s.tenant_id + AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) + AND s.configuration->'environment'->>'type' = 'self_hosted' + AND e.status NOT IN ('failed', 'expired') + AND EXISTS (SELECT 1 FROM execution_project_scopes p WHERE p.tenant_id = c.tenant_id) + )); + +-- +goose Down +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM devices WHERE executor_key_id IS NOT NULL) THEN + RAISE EXCEPTION 'Cannot remove Runtime enrollment while bound devices exist'; + END IF; +END $$; +-- +goose StatementEnd +DROP VIEW runtime_device_authority; +ALTER TABLE devices DROP CONSTRAINT device_credential_source, + DROP COLUMN executor_key_id, ALTER COLUMN credential_hash SET NOT NULL; diff --git a/services/agents-api/tests/e2b_native_isolation.py b/services/agents-api/tests/e2b_native_isolation.py index 7a2d7a9ed..bcbeff3af 100644 --- a/services/agents-api/tests/e2b_native_isolation.py +++ b/services/agents-api/tests/e2b_native_isolation.py @@ -10,9 +10,9 @@ fixture = json.loads(Path('/workspace/isolation-fixture.json').read_text()) paths = ['/environment/staging/canary', - '/home/runtime/.parsar/parsar-daemon/default/auth.json', + '/home/runtime/.parsar/parsar-daemon/executor-key.json', fixture['history_path'], - '/proc/1/root/home/runtime/.parsar/parsar-daemon/default/auth.json', + '/proc/1/root/home/runtime/.parsar/parsar-daemon/executor-key.json', '/root/.parsar/e2b/ready.json', '/var/run/docker.sock'] diff --git a/services/agents-api/tests/native/README.md b/services/agents-api/tests/native/README.md deleted file mode 100644 index 2e63bf238..000000000 --- a/services/agents-api/tests/native/README.md +++ /dev/null @@ -1,309 +0,0 @@ -# Native Environment probes - -These fixtures exercise pinned native execution against the actual Agents API -registry/relay and a dedicated execution PostgreSQL database. They are opt-in; -ordinary CI skips native binaries and paid model calls when prerequisites are absent. -The standalone public fixture below exercises the initial `self_hosted` text -profile. The other probes exercise private adapter boundaries. None establishes -complete Agents API compatibility; use the protocol coverage ledger for those gaps. - -## Public standalone self-hosted execution - -Build the standalone service with `make build-agents-api`. Supply the pinned SDK, -native daemon, Codex 0.153.4, executor launcher, private proof directory, dedicated -execution test database, local Docker image and real MiniMax credential described -below, then run: - -```sh -export PARSAR_AGENTS_API_SERVER_BIN="$HOME/.parsar/build/agents-api/agents-api" -export PARSAR_OFFICIAL_SDK_PYTHON='' -go test ./services/agents-api/internal/store \ - -run '^TestNativePublicSelfHostedStandalone$' -count=1 -v -timeout=12m -``` - -`official_self_hosted.py` creates empty Sessions through ordinary and streamed -official-client requests, then submits both text inputs through the built server. -The first request must remain open beyond the ordinary HTTP write timeout while -there is no executor or daemon. No Turn or Item may exist during that wait. The -caller starts the executor using the returned Environment ID and `remote_url` -unchanged; the registered daemon and Worker perform preparation and admission. -The second input starts with a connected executor and retained native history. -Both real model Turns must execute the exact remote command with observed cwd, -stdout/stderr, exit 7, retained files and remembered first-Turn context. SDK and -independent raw SSE observers verify activity ordering, responses, query recovery, -tenant isolation and retries without additional Turns or commands. -SDK/raw Environment reads verify pending and connected observations before daemon -startup and safe metadata around both Turns. Actual workspace files must not appear -as API-installed resources. The connect-only executor key must fail public retrieval. -Post-Turn reads may observe reconnection; they do not assert immediate quiescence or -a fixed disconnection deadline. - -Private fixture writes provision only operator identity/device credentials, never -Sessions or input reservations. Store reads independently check execution identity -and native command evidence. Unsupported initial input, functions and mixed input -must fail before persistence. This fixture does not cover all Environment resources, -hosted providers, public cancellation, process isolation or unknown-effect recovery. -It incurs real provider usage and must run serially with other execution-lease -tests on Linux. Passing evidence is produced only by an actual successful run, -under `PARSAR_NATIVE_PROOF_DIR/public-self-hosted-*`. - -`relay_probe.rs` is compiled as an example of the pinned `codex-exec-server` crate. -`TestNativeHarnessRelayPostgreSQLAndProcessRecovery` runs it against the Go registry. -It uses synthetic scoped credentials and zero model calls. - -`environment_model_probe.py` drives the unmodified Codex 0.153.4 **app-server** over -stdio. The harness owns the model/tool loop; this script only sends user Turns and -observes native events, real files and processes. It calls the real MiniMax-M3 -Responses API, never a mock endpoint. Run it through the Go test, which owns the -tenant, Environment, scoped synthetic credentials and execution lease: - -```sh -export PARSAR_AGENTS_API_TEST_DATABASE_URL='' -export PARSAR_CODEX_BINARY='' -export PARSAR_EXECUTOR_PROOF_DIR="$HOME/.parsar/placement-proof" -export PARSAR_PLACEMENT_EXECUTOR_IMAGE='sha256:' -export PARSAR_PLACEMENT_MODEL_KEY_FILE="$HOME/.parsar/secrets/minimax.key" -mkdir -p "$PARSAR_EXECUTOR_PROOF_DIR" -chmod 700 "$PARSAR_EXECUTOR_PROOF_DIR" -go test ./services/agents-api/internal/store \ - -run '^TestNativeAppServerRemoteModelPlacement$' -count=1 -v -timeout=12m -``` - -Run on Linux with Python 3.10+, Docker, a loaded Debian image containing Bash and -coreutils, and the native executable compatible with that image. The fixture does -not pull images or install packages. Optional HTTP(S) proxy variables are forwarded -to the harness; loopback registry traffic bypasses them. Serialize this test with -other execution-lease/database tests. Model calls incur provider usage. - -The executor has its own container PID/filesystem view and mounts only its private -state, test workspace and read-only native executable. Provider credentials and -harness history stay outside it. Host networking is used for the loopback registry; -this is neither network isolation nor a production deployment recipe. Generated -code still shares the executor's user/process visibility. A scoped executor token -must not be confused with a caller, device or provider credential. - -The probe requires explicit remote selections, verifies a workspace absent on the -harness host, checks remote instructions and actual command output/exit/files, and -cold-resumes the same native thread from retained harness history. It records that native interruption preserves the demonstrated background command, -then targets that Turn's process through native list/terminate RPCs and independently -observes PID exit and stopped heartbeats. A second configuration records only the -names of exposed credential variables under the native default policy. Secrets are -checked before evidence is saved. The test removes only its owned container and -processes; private evidence/history remain under the configured evidence directory. -Other binary versions, native credential lifetime, unknown-effect recovery, production -TLS/domain authentication and full API/daemon lifecycle need separate acceptance. - -A successful probe reports `characterized_with_blockers`: its native behavior -assertions passed, while public dispatch/cancellation integration remains unimplemented. -It is not a passing claim for the complete Environment feature. - -## Registered daemon adapter - -`TestNativeDaemonRemoteEnvironment` sends the typed remote descriptor through a -real authenticated daemon/gateway, using the same registry and executor. It creates -harness credentials after daemon startup, so preloaded transport environment cannot -satisfy the test. The fixed native version must advertise the capability through -its actual heartbeat. Supply the placement prerequisites above plus: - -```sh -export PARSAR_NATIVE_DAEMON_BIN='' -export PARSAR_NATIVE_PROOF_DIR="$HOME/.parsar/daemon-environment-proof" -mkdir -p "$PARSAR_NATIVE_PROOF_DIR" -chmod 700 "$PARSAR_NATIVE_PROOF_DIR" -go test ./services/agents-api/internal/store \ - -run '^TestNativeDaemonRemoteEnvironment$' -count=1 -v -timeout=12m -``` - -With the same prerequisites, run `TestNativeDaemonPreparedRemoteEnvironment` to -exercise private prepare/ready/start through the registered daemon. Its separate -preparation subscription creates no Run, then the actual Run starts using the -returned handle. This repeats real remote command/file, cold-history and -cancellation acceptance; it does not enable public Environment admission. Controlled -subprocess/router tests establish deferred-start ownership independently. Both -variants obtain their harness credential from the current registry under the -fixture's execution owner; after real execution, they release it and verify that -native preparation rejects the former credential. No static harness-key file is -used. Public caller principal identity and Worker admission remain separate work. - -Run `TestNativePreparedWorkerRemoteEnvironment` with the same prerequisites to -exercise the real Worker above these controls. It reserves input without a -Turn, selects and binds a capable daemon, resolves a live harness credential, -prepares that daemon, atomically claims the original batch and persists ordinary -events/completion. Two real model -Turns verify remote instructions, exact command cwd/output/exit, retained files and -cold native history. Repeated reservation submission must return replay receipts -without allocating credentials or executing another command. Controlled Store and -gateway tests separately cover preparation failure, expiry/deletion/cancellation, -control-only Start rejection and cancellation while Start is pending. This fixture -configures the Worker resolver before startup. It additionally requires -`PARSAR_OFFICIAL_SDK_PYTHON` pointing to the fixed SDK in `upstream.json`. -Strict SDK and independent raw HTTP/SSE observers subscribe before reservation, -verify the connection action without Turns/Items, and supply the returned URL and -Environment ID unchanged to the caller-started executor. Scheduling begins after -that offline snapshot and initial connection; native readiness, promotion and both -Runs remain Worker-owned. The observers verify action clearing before the first -Turn, both completed Turns/Items, tenant isolation and recovery through a new -client. Private evidence includes `public-environment/public-environment-proof.json`. -Session provisioning and input reservation stay private in this fixture; the -standalone fixture above owns public creation/input acceptance. Complete -Environment lifecycle remains separate work. -The current daemon's pending-start cancellation acknowledgment may omit Outcome; -controlled coverage verifies conservative failure without final Done as well -as cancellation with a supplied outcome. It does not claim complete native -pending-start cancellation results or immediate process quiescence. - -The fixture explicitly sets daemon `PARSAR_CODEX_BIN` to `PARSAR_CODEX_BINARY`. -It checks invalid transient authorization, remote instructions/cwd/output/exit/files, -release and same-thread cold continuation, then sends `prompt_cancel` during an -actual remote command. PID exit and stopped heartbeats are observed independently -while the daemon, registry and executor remain running; the measured cleanup delay -is recorded, with no immediate-quiescence claim. Provider usage is real MiniMax-M3. - -The Environment token must be absent from persisted files and responses. The device -credential remains in its profile; the existing provider adapter may store its key -in private harness `config.toml`. Neither is mounted into the executor. The explicit -shell policy checks inheritance, not arbitrary same-user process visibility. -`remote-adapter-proof.json` describes this bounded daemon acceptance; public input -admission, complete lifecycle, files/templates and broader resource projection still -need their own implementation and real acceptance. - -## Separate executor launcher - -Build `agents-api-codex-executor` with `make build-agents-executor`, and compile -the current `relay_probe.rs` against the pinned upstream libraries as described -in the [service guide](../../README.md#native-executor-transport-prerequisite). -Then supply the ordinary PostgreSQL/native/image prerequisites above plus: - -```sh -export PARSAR_EXECUTOR_LAUNCHER="$HOME/.parsar/build/agents-executor/agents-api-codex-executor" -export PARSAR_NATIVE_RELAY_PROBE='' -go test ./services/agents-api/internal/store -run '^TestNativeExecutorLauncherTLSAndHelpers$' -count=1 -v -timeout=6m -``` - -This fixture uses controlled Docker DNS and a test CA with actual HTTPS/WSS. -It rejects an untrusted CA and wrong hostname before registry HTTP handling, -then verifies native commands, a 128 KiB file, connection recovery, fresh reads, -native filesystem/argv0 helper modes, read-only enforcement and graceful launcher -exit. It mounts the full native installation with its resources. Docker's outer -seccomp/AppArmor restrictions are relaxed solely so the native sandbox can run -inside the test container; this is not a production isolation recipe or public -DNS/certificate deployment. Model calls are zero. - -With `PARSAR_EXECUTOR_LAUNCHER` set, `TestNativeDaemonRemoteEnvironment` uses the -same new launcher and full native installation instead of stock CLI registration. -Its provider calls remain actual MiniMax. The provisioned executor JSON is the -only permitted persistence of that executor credential; harness credentials stay -transient. This second fixture uses loopback HTTP and separately verifies the -authenticated daemon, cold history/files and cancellation. Neither fixture enables -public `self_hosted` admission or proves complete Environment compatibility. - -The prepared Worker fixture (`TestNativePreparedWorkerRemoteEnvironment`) requires -the built launcher and issues a principal key before creating its Environment -Session. It verifies the serialized key ID and absent exact restriction before -real-provider commands/files and cold continuation. PostgreSQL/HTTP fixtures cover -same-principal multiple Sessions, cross-principal rejection, rotation/revocation, -restart and deletion. These checks do not enable public Environment admission. - -## Shared native filesystem owner - -`TestNativeSharedEnvironmentFiles` is an opt-in ownership experiment. It embeds -the unchanged upstream app-server through its public in-process interface and -injects the same `EnvironmentManager` used for direct native filesystem calls. -The Go fixture owns the actual registry, dedicated PostgreSQL lease and credentials, -caller executor container and remote-only workspace. This does not change the -production daemon or expose public file endpoints. - -Compile `shared_files_probe.rs` as `app-server/examples/parsar_shared_files_probe.rs` -in a task-owned copy of native commit -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`; copy its `shared_files/` modules beside -that example. Keep build source, cache and output under `~/.parsar/`. Use Rust -1.95.0 and the matching native installation/resources, with the existing OpenSSL -and platform build prerequisites. The probe follows native startup with 16 MiB -main-runtime and worker stacks. The upstream release manifest says `0.153.4` -while its tracked lock retains `0.0.0` for 149 workspace packages. Normalize only -those workspace versions and verify that all third-party packages, checksums and -dependency edges remain identical, then retain that build lock and use `--locked`. - -From the copied upstream `codex-rs` directory: - -```sh -cargo build --locked -p codex-app-server --example parsar_shared_files_probe -cargo clippy --locked -p codex-app-server --example parsar_shared_files_probe -- -D warnings -rustfmt --check --edition 2024 app-server/examples/parsar_shared_files_probe.rs -``` - -Supply the PostgreSQL, native binary, launcher, image, private proof and real model -key prerequisites above, plus `PARSAR_SHARED_FILES_PROBE` pointing to this example. -From the Parsar worktree, run: - -```sh -go test ./services/agents-api/internal/store \ - -run '^TestNativeSharedEnvironmentFiles$' -count=1 -v -timeout=12m -``` - -The fixture requires a 128 KiB binary round trip, actual metadata/directory reads, -and direct filesystem writes during an independently observed blocked native model -command. The same pair remains connected through both execution and file access. -A fresh harness process must resume native history and observe retained files. -Expected command lifecycle, cwd/stdout/stderr/exit, answers and side effects are -checked separately. All model calls use the real provider; synthetic credentials -and test bytes do not replace that acceptance. - -Results characterize shared access with production composition blockers. The -pinned in-process router can silently drop non-required notifications when full -without emitting `Lagged`; the dedicated drain and bounded fixture establish only -the expected observations of this run. Raw stdio initialization, lossless event -handling, process/credential lifetime and public path/reference/pagination semantics -need separate acceptance before adopting this runtime or exposing Environment files. - -The separate [raw manager qualification](raw_manager/README.md) investigates an -explicit, pinned native patch that shares the stock raw runner's manager while -retaining its transport assembly. Its deterministic no-model checks qualify only -that seam. They neither replace this real-provider proof nor establish production -Files ownership or public protocol acceptance. - -The [raw remote Files fixture](raw_files/README.md) combines that hook with the -pinned native Unix-socket client, reusing the same registry/container and typed -Files acceptance. Its real first/fresh phases cover file access during execution -and cold history. The native client's internal unbounded event queue remains an -explicit production-adoption limit. Its dedicated first/cancel/fresh scenario -checks observed interruption, exact native termination ownership, independent -command exit, post-cancel Files and retained interrupted history; ordinary -first/fresh acceptance does not cover cancellation. - - -## Public cancellation with the optional harness - -`TestNativePublicSelfHostedCancellationStandalone` can use the separately built -private harness through the actual daemon adapter. Set -`PARSAR_PUBLIC_HARNESS_ARTIFACT` to its absolute path, alongside the existing -built server/daemon, pinned native helper/executor, SDK, image and private key-file -inputs. `PARSAR_PLACEMENT_MODEL` selects the real model for this fixture (default -`MiniMax-M3`); `PARSAR_PLACEMENT_MODEL_BASE_URL` selects its native Responses -provider endpoint (default `https://api.minimax.cn/v1`). These are test inputs, -not public API fields or production configuration options. - -This mode runs the daemon in a task-owned Linux amd64 container using the existing -exact-digest executor image, which must include `strace` for this opt-in mode. It mounts the three binaries and host CA bundle read-only, uses native -`/etc/codex/config.toml`, and shares only required task daemon state and a short, -private HOME. It does not mount the shared operator home, Docker socket, API key -file or observer directory. Host networking connects to the existing local test -API/proxy. Explicit process proxy settings override Docker client defaults. This -is a qualified test placement, not a production isolation profile. -Provider credentials are passed through a private environment file and removed -on cleanup. No shell wrapper launches the harness. - -`strace` follows the daemon from startup through final retries and records every -successful harness exec, including short-lived launches, in separate per-process -files. Owner snapshots do not supply launch counts. The tracer runs with the same -non-root user, dropped capabilities and default seccomp policy; no ptrace privilege -or native sandbox relaxation is added. Only exec syscalls are recorded, without -expanding environment values. - -The fixture observes actual executable identity and Environment/workspace binding -before cancellation and during cold continuation. Existing command, file, history, -retry, receipt and SDK/raw-event assertions remain in force; native process and -private IPC release are checked separately. The default stock-helper fixture is -unchanged. This opt-in currently applies only to the public cancellation fixture. -Its success does not qualify public Files, interrupted-work replay, general remote -mutation retirement, other engines or complete protocol compatibility. diff --git a/services/agents-api/tests/native/directory/README.md b/services/agents-api/tests/native/directory/README.md deleted file mode 100644 index 02a24c660..000000000 --- a/services/agents-api/tests/native/directory/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Native directory helper acceptance - -`TestNativeExecutorDirectoryHelper` uses a real PostgreSQL registry, its issued -executor/harness credentials, the pinned native client and an actual Docker -executor. It directly invokes the installed helper through native process RPC, -requires the reported Linux sandbox, and waits for exit and output closure. -It checks root metadata, a 5,000-entry directory with bounded output, an empty -directory, symlink ancestry and invalid path/limit rejection. These are mechanism -tests with synthetic files and zero model calls. They do not establish public -Files compatibility, adapter lifecycle integration or real model execution. - -Build `probe.rs` as an example of `codex-exec-server` in an isolated export of -commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, using the repository's pinned -native toolchain and the existing workspace-lock normalization. No native source -or protocol patch is required. Keep sources, build state and evidence below -`~/.parsar/`. Build the production helper with `make build-agents-executor`. - -Set the existing private integration-test database URL, `PARSAR_DIRECTORY_PROBE`, -`PARSAR_DIRECTORY_HELPER`, `PARSAR_EXECUTOR_PROOF_DIR`, `PARSAR_EXECUTOR_LAUNCHER`, -`PARSAR_CODEX_BINARY`, and the digest-pinned `PARSAR_PLACEMENT_EXECUTOR_IMAGE`. -The latter prerequisites follow the [native fixtures](../README.md). -Then run: - -```sh -go test ./services/agents-api/internal/store -run '^TestNativeExecutorDirectoryHelper$' -count=1 -v -``` - -The fixture removes its container and temporary credential. Evidence retains no -provider credentials. Directory-descriptor unit tests separately control ancestor -replacement between opens and before enumeration, verify the scan bound, and -check descriptor release. Production consumers must also qualify cancellation, -transport uncertainty, installation trust and their exact owner/authorization -binding before admission. Adapter changes require real model calls before/after -observations through the retained harness; this helper-only fixture is not a -replacement for that acceptance. diff --git a/services/agents-api/tests/native/directory/probe.rs b/services/agents-api/tests/native/directory/probe.rs deleted file mode 100644 index 7eb187943..000000000 --- a/services/agents-api/tests/native/directory/probe.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::{Context, Result, ensure}; -use codex_exec_server::{ - EnvironmentManager, ExecOutputStream, ExecParams, ExecProcess, FileSystemSandboxContext, - ProcessId, -}; -use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::{ - FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, - FileSystemSpecialPath, NetworkSandboxPolicy, -}; -use codex_utils_path_uri::PathUri; -use tokio::time::timeout; - -const TIMEOUT: Duration = Duration::from_secs(15); - -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() -> Result<()> { - timeout(Duration::from_secs(90), run()).await??; - Ok(()) -} - -async fn run() -> Result<()> { - let proof = PathBuf::from(std::env::var("PARSAR_NATIVE_ENV_PROOF")?); - let workspace = PathBuf::from(std::env::var("PARSAR_DIRECTORY_WORKSPACE")?); - let helper = PathBuf::from("/usr/local/bin/scoped-directory"); - let manager = EnvironmentManager::from_env( - None, - HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - ) - .await?; - ensure!(manager.try_local_environment().is_none(), "local fallback"); - let environment = manager - .default_environment() - .context("remote environment")?; - ensure!(environment.is_remote(), "remote backend required"); - timeout(TIMEOUT, environment.wait_until_ready()).await??; - let cwd = PathUri::from_host_native_path(&workspace)?; - let policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: PathUri::from_host_native_path(&workspace)?, - }, - FileSystemAccessMode::Read, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Path { - path: PathUri::from_host_native_path(&helper)?, - }, - FileSystemAccessMode::Read, - ), - FileSystemSandboxEntry::new( - FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - FileSystemAccessMode::Read, - ), - ]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( - PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), - cwd.clone(), - ); - let mut observed = Vec::new(); - for (index, (relative, limit)) in [ - ("", 64), - ("sub", 1), - ("empty", 1), - ("a/sub", 16), - ("../outside", 2), - ("sub", 0), - ] - .into_iter() - .enumerate() - { - let mut request = params( - &format!("directory-{index}"), - &cwd, - vec![ - helper.to_string_lossy().into_owned(), - workspace.to_string_lossy().into_owned(), - relative.into(), - limit.to_string(), - ], - ); - request.sandbox = Some(sandbox.clone()); - let started = environment.get_exec_backend().start(request).await?; - let sandbox_type = format!("{:?}", started.sandbox_type); - ensure!( - sandbox_type == "Some(LinuxSeccomp)", - "required Linux sandbox missing: {sandbox_type}" - ); - let (stdout, stderr, exit) = read_closed(started.process.as_ref()).await?; - ensure!( - exit == Some(0) && stderr.is_empty(), - "directory helper execution failed: {:?} {}", - exit, - String::from_utf8_lossy(&stderr) - ); - let value: serde_json::Value = serde_json::from_slice(&stdout)?; - ensure!(value["version"] == 1, "helper version mismatch"); - if relative == "" { - let entries = value["directory"]["entries"] - .as_array() - .context("root entries")?; - ensure!(entries.len() == 4, "root count mismatch: {value}"); - ensure!( - entries - .iter() - .any(|e| e["name"] == "retained.txt" && e["size_bytes"] == 8), - "retained metadata mismatch" - ); - } else if relative == "sub" && limit == 1 { - ensure!( - value["directory"]["entries"] - .as_array() - .context("limited entries")? - .len() - == 1 - && value["directory"]["truncated"] == true, - "bounded scan mismatch" - ); - } else if relative == "empty" { - ensure!( - value["directory"]["entries"] == serde_json::json!([]) - && value["directory"]["truncated"] == false, - "empty mismatch" - ); - } else { - ensure!(value["error"].is_string(), "expected rejection: {value}"); - } - observed.push(serde_json::json!({"path":relative,"limit":limit,"response":value,"sandbox":sandbox_type,"exit":exit,"closed":true})); - } - std::fs::write( - proof.join("directory-native.json"), - serde_json::to_vec_pretty( - &serde_json::json!({"native_source":"3d2ee51ca2d5db578f328aa75e20aa22c0197c9a","observations":observed,"model_calls":0,"scope":"authenticated native process RPC, actual Docker executor, explicit argv, read-only sandbox, helper output/exit/close; not public API acceptance"}), - )?, - )?; - Ok(()) -} - -fn params(id: &str, cwd: &PathUri, argv: Vec) -> ExecParams { - ExecParams { - process_id: ProcessId::from(id), - argv, - cwd: cwd.clone(), - shell_snapshot: None, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: false, - arg0: None, - sandbox: None, - enforce_managed_network: false, - managed_network: None, - network_proxy: None, - } -} - -async fn read_closed(process: &dyn ExecProcess) -> Result<(Vec, Vec, Option)> { - timeout(TIMEOUT, async { - let mut after = None; - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - loop { - let result = process.read(after, Some(65536), Some(1000)).await?; - ensure!(result.failure.is_none(), "process failed"); - for chunk in result.chunks { - match chunk.stream { - ExecOutputStream::Stdout => stdout.extend(chunk.chunk.0), - ExecOutputStream::Stderr => stderr.extend(chunk.chunk.0), - ExecOutputStream::Pty => anyhow::bail!("unexpected PTY"), - } - } - ensure!( - stdout.len() + stderr.len() <= 2 * 1024 * 1024, - "output cap exceeded" - ); - if result.closed { - return Ok((stdout, stderr, result.exit_code)); - } - after = result.next_seq.checked_sub(1); - } - }) - .await? -} diff --git a/services/agents-api/tests/native/environment_model_probe.py b/services/agents-api/tests/native/environment_model_probe.py deleted file mode 100644 index 543c8d2c8..000000000 --- a/services/agents-api/tests/native/environment_model_probe.py +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env python3 -"""Opt-in stock app-server placement evidence; never a substitute model/tool loop.""" -import hashlib -import json -import os -from pathlib import Path -import queue -import subprocess -import threading -import time -import uuid - - -class NativeRPCError(RuntimeError): - def __init__(self, method, error): - super().__init__('native request failed: ' + method) - self.error = error - - -class AppServer: - def __init__(self, binary, root, env, label): - self.messages, self.events, self.sequence = queue.Queue(), [], 0 - self.log = (root / (label + '.stderr')).open('wb') - self.process = subprocess.Popen( - [binary, 'app-server'], cwd=root / 'harness', env=env, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.log, - text=True, - ) - threading.Thread(target=self.read, daemon=True).start() - self.request('initialize', {'clientInfo': {'name': 'parsar_environment_probe', 'version': '1'}, - 'capabilities': {'experimentalApi': True}}) - self.send({'method': 'initialized'}) - - def read(self): - for line in self.process.stdout: - self.messages.put(json.loads(line)) - self.messages.put(None) - - def send(self, message): - self.process.stdin.write(json.dumps(message) + '\n') - self.process.stdin.flush() - - def receive(self, timeout=120): - message = self.messages.get(timeout=timeout) - if message is None: - raise RuntimeError('app-server exited') - self.events.append(message) - if 'method' in message and 'id' in message: - self.send({'id': message['id'], 'error': {'code': -32601, 'message': 'Unexpected request in placement probe'}}) - raise RuntimeError('unexpected approval or client tool request') - return message - - def request(self, method, params, timeout=120): - self.sequence += 1 - request_id = self.sequence - self.send({'id': request_id, 'method': method, 'params': params}) - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - message = self.receive(max(0.1, deadline - time.monotonic())) - if message.get('id') == request_id: - if 'error' in message: - raise NativeRPCError(method, message['error']) - return message['result'] - raise TimeoutError(method) - - def turn(self, thread, prompt, selection): - return self.request('turn/start', {'threadId': thread, 'input': [{'type': 'text', 'text': prompt}], - 'environments': selection})['turn']['id'] - - def completed(self, turn, timeout=180): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - for message in self.events: - if message.get('method') == 'turn/completed' and message['params']['turn']['id'] == turn: - return message['params']['turn'] - self.receive(max(0.1, deadline - time.monotonic())) - raise TimeoutError('native turn completion') - - def close(self): - if self.process.poll() is None: - self.process.terminate() - try: - self.process.wait(timeout=10) - except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait(timeout=5) - self.log.close() - - -def completed_items(app, turn, kind): - return [e['params']['item'] for e in app.events - if e.get('method') == 'item/completed' and e['params'].get('turnId') == turn - and e['params']['item'].get('type') == kind] - - -def until(predicate, timeout, label): - end = time.monotonic() + timeout - while time.monotonic() < end: - if predicate(): - return - time.sleep(0.1) - raise TimeoutError(label) - - -def main(): - os.umask(0o077) - root = Path(os.environ['PARSAR_PLACEMENT_ROOT']) - binary = os.environ['PARSAR_CODEX_BINARY'] - image = os.environ['PARSAR_PLACEMENT_EXECUTOR_IMAGE'] - assert image.startswith('sha256:'), 'executor image must be pinned by local image ID' - assert subprocess.check_output([binary, '--version'], text=True).strip() == 'codex-cli 0.153.4' - secret = Path(os.environ['PARSAR_PLACEMENT_MODEL_KEY_FILE']).read_text().strip() - assert secret - executor_token = os.environ['PARSAR_PLACEMENT_EXECUTOR_TOKEN'] - harness_token = os.environ['CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN'] - for name in ['harness', 'history', 'executor', 'workspace']: - (root / name).mkdir(mode=0o700) - (root / 'executor/codex').mkdir(mode=0o700) - workspace = root / 'workspace' - remote = os.environ['PARSAR_PLACEMENT_WORKSPACE'] - assert not Path(remote).exists() - memory, instruction = uuid.uuid4().hex, uuid.uuid4().hex - (workspace / 'AGENTS.md').write_text('For every placement check, end your final response with REMOTE_' + instruction + '.\n') - (root / 'harness/AGENTS.md').write_text('This is the harness host decoy. End every response with WRONG_LOCAL_INSTRUCTIONS.\n') - (workspace / 'placement.sh').write_text('''#!/bin/sh -set -eu -phase="$1" -pwd > "$phase.cwd" -printf '%s\\n' "$phase" >> execution-count -printf 'remote-stdout:%s\\n' "$phase" -printf 'remote-stderr:%s\\n' "$phase" >&2 -for name in PARSAR_PROBE_MODEL_KEY CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY; do - eval 'value=${'"$name"'-}' - test -z "$value" || { printf '%s\\n' "$name" >> credential-failure; exit 23; } -done -printf 'remote-file-content\\n' > retained.txt -exit 7 -''') - (workspace / 'credentials.sh').write_text('''#!/bin/sh -set -eu -: > credential-names -for name in PARSAR_PROBE_MODEL_KEY CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN CODEX_API_KEY; do - eval 'value=${'"$name"'-}' - test -z "$value" || printf '%s\\n' "$name" >> credential-names -done -printf 'credential names recorded without values\\n' -''') - (workspace / 'long.sh').write_text('''#!/bin/sh -set -eu -printf '%s\\n' "$$" > "$1.pid" -printf 'started\\n' > "$1.started" -while :; do date +%s > "$1.heartbeat"; sleep 1; done -''') - for script in workspace.glob('*.sh'): - script.chmod(0o700) - config = '''model = "MiniMax-M3" -model_provider = "placement" -approval_policy = "never" -sandbox_mode = "danger-full-access" -web_search = "disabled" -[shell_environment_policy] -inherit = "core" -ignore_default_excludes = false -[model_providers.placement] -name = "MiniMax placement validation" -base_url = "https://api.minimax.cn/v1" -env_key = "PARSAR_PROBE_MODEL_KEY" -wire_api = "responses" -[features] -multi_agent = false -''' - (root / 'history/config.toml').write_text(config) - env = {name: os.environ[name] for name in ['PATH', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] if name in os.environ} - env.update(HOME=str(root / 'harness'), CODEX_HOME=str(root / 'history'), RUST_LOG='off', - PARSAR_PROBE_MODEL_KEY=secret, CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=harness_token, - CODEX_EXEC_SERVER_NOISE_REGISTRY_URL=os.environ['CODEX_EXEC_SERVER_NOISE_REGISTRY_URL'], - CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID=os.environ['CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID']) - docker_env = os.environ.copy() - docker_env['CODEX_API_KEY'] = executor_token - container = os.environ['PARSAR_PLACEMENT_CONTAINER'] - args = ['docker', 'run', '--detach', '--name', container, '--network', 'host', - '--user', str(os.getuid()) + ':' + str(os.getgid()), '--cap-drop', 'ALL', - '--security-opt', 'no-new-privileges', '--env', 'CODEX_API_KEY', - '--env', 'HOME=/executor', '--env', 'CODEX_HOME=/executor/codex', '--env', 'RUST_LOG=off', - '--env', 'NO_PROXY=127.0.0.1,localhost', '--workdir', remote, - '--mount', f'type=bind,src={binary},dst=/usr/local/bin/codex,readonly', - '--mount', f'type=bind,src={root / "executor"},dst=/executor', - '--mount', f'type=bind,src={workspace},dst={remote}', - '--entrypoint', '/usr/local/bin/codex', image, 'exec-server', '--remote', - env['CODEX_EXEC_SERVER_NOISE_REGISTRY_URL'], '--environment-id', env['CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID']] - apps, report = [], {'native_version': '0.153.4', 'executor_image': image, 'remote_cwd': remote, - 'native_source': '3d2ee51ca2d5db578f328aa75e20aa22c0197c9a', - 'native_binary_sha256': hashlib.sha256(Path(binary).read_bytes()).hexdigest(), - 'probe_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} - selection = [{'environmentId': 'remote', 'cwd': remote, 'runtimeWorkspaceRoots': [remote]}] - def start(label, override=None): - app = AppServer(binary, root, env if override is None else override, label) - apps.append(app) - return app - def persist(): - payload = json.dumps({'report': report, 'events': [a.events for a in apps]}, indent=2) - files = [p for folder in ['history', 'executor'] for p in (root / folder).rglob('*') if p.is_file()] - files += list(root.glob('*.stderr')) - for value in [secret, executor_token, harness_token]: - assert value not in payload, 'credential in native payload' - assert all(value.encode() not in p.read_bytes() for p in files), 'credential in native history/logs' - (root / 'proof.json').write_text(payload) - try: - subprocess.run(args, env=docker_env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=30) - time.sleep(1) - running = subprocess.check_output(['docker', 'inspect', '--format', '{{.State.Running}}', container], text=True).strip() - assert running == 'true', 'native executor exited during startup' - app = start('first') - assert app.request('environment/status', {'environmentId': 'local'})['status'] == 'unknown' - report['remote_info'] = app.request('environment/info', {'environmentId': 'remote'}, timeout=40) - params = {'cwd': str(root / 'harness'), 'model': 'MiniMax-M3', 'modelProvider': 'placement', - 'approvalPolicy': 'never', 'sandbox': 'danger-full-access', 'environments': selection} - started = app.request('thread/start', params) - thread = started['thread']['id'] - report['thread_start'] = started - turn = app.turn(thread, 'Placement check. Remember the memory word ' + memory + '. Run the exact command `./placement.sh first` once using the native shell tool. Exit 7 is intentional; do not retry or change the script. Report its stdout/stderr and memory word briefly.', selection) - assert app.completed(turn)['status'] == 'completed' - commands = completed_items(app, turn, 'commandExecution') - assert any(c.get('exitCode') == 7 and 'remote-stdout:first' in c.get('aggregatedOutput', '') and 'remote-stderr:first' in c.get('aggregatedOutput', '') for c in commands), 'native output/exit evidence missing' - assert (workspace / 'first.cwd').read_text().strip() == remote - assert not (workspace / 'credential-failure').exists() - assert instruction in json.dumps(completed_items(app, turn, 'agentMessage')), 'remote AGENTS instructions absent' - assert 'WRONG_LOCAL_INSTRUCTIONS' not in json.dumps(app.events) - report['first_turn'] = turn - app.close() - time.sleep(2) - app = start('resume') - resumed = app.request('thread/resume', {'threadId': thread, 'approvalPolicy': 'never', 'sandbox': 'danger-full-access'}) - assert resumed['thread']['id'] == thread - report['thread_resume'] = resumed - turn = app.turn(thread, 'Placement check. State the memory word from our earlier conversation. Run the exact command `./placement.sh resumed` once and read retained.txt using the native shell. Exit 7 is intentional; do not retry or change the script.', selection) - assert app.completed(turn)['status'] == 'completed' - fresh_events = completed_items(app, turn, 'agentMessage') - assert memory in json.dumps(fresh_events), 'native history was not recalled' - assert instruction in json.dumps(fresh_events), 'remote instruction not retained/applied' - assert 'WRONG_LOCAL_INSTRUCTIONS' not in json.dumps(fresh_events) - assert (workspace / 'resumed.cwd').read_text().strip() == remote - assert (workspace / 'execution-count').read_text().splitlines() == ['first', 'resumed'] - report['cold_resume_turn'] = turn - def alive(label): - pid = (workspace / (label + '.pid')).read_text().strip() - result = subprocess.run(['docker', 'exec', container, 'sh', '-c', 'test -r /proc/"$1"/stat && test "$(cut -d " " -f 3 /proc/"$1"/stat)" != Z', 'probe', pid], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) - assert result.returncode in (0, 1), 'remote process observation failed' - assert subprocess.check_output(['docker', 'inspect', '--format', '{{.State.Running}}', container], text=True).strip() == 'true' - return result.returncode == 0 - turn = app.turn(thread, 'Run the exact command `./long.sh cancelled` with the native shell tool and keep waiting for it. It will be interrupted externally. Do not start any other command.', selection) - until(lambda: (workspace / 'cancelled.started').exists(), 120, 'remote long command start') - assert alive('cancelled') - app.request('turn/interrupt', {'threadId': thread, 'turnId': turn}) - ended = app.completed(turn) - assert ended['status'] == 'interrupted' - time.sleep(2) - active_after_interrupt = alive('cancelled') - heartbeat = (workspace / 'cancelled.heartbeat').read_text() - time.sleep(2) - report['cancel'] = {'turn': turn, 'native_status': ended['status'], - 'remote_alive_after_interrupt': active_after_interrupt and alive('cancelled'), - 'heartbeat_advanced_after_interrupt': heartbeat != (workspace / 'cancelled.heartbeat').read_text()} - assert report['cancel']['remote_alive_after_interrupt'] and report['cancel']['heartbeat_advanced_after_interrupt'], 'pinned native cancellation behavior changed; reassess the integration gap' - owned = {(e['params']['item']['id'], e['params']['item'].get('processId')) for e in app.events - if e.get('method') in ('item/started', 'item/completed') and e['params'].get('turnId') == turn - and e['params']['item'].get('type') == 'commandExecution'} - listed = app.request('thread/backgroundTerminals/list', {'threadId': thread, 'limit': 100}) - assert listed['nextCursor'] is None, 'unexpected background-terminal pagination in this bounded fixture' - targets = [p for p in listed['data'] if (p['itemId'], p['processId']) in owned] - assert len(targets) == 1 and targets[0]['cwd'] == remote, 'cannot identify the current Turn process' - stopped_at = time.monotonic() - receipt = app.request('thread/backgroundTerminals/terminate', {'threadId': thread, 'processId': targets[0]['processId']}) - assert receipt['terminated'] is True - until(lambda: not alive('cancelled'), 10, 'remote exit after native targeted termination') - heartbeat = (workspace / 'cancelled.heartbeat').read_text() - time.sleep(2) - assert (workspace / 'cancelled.heartbeat').read_text() == heartbeat - report['cancel']['native_targeted_termination'] = {'target': targets[0], 'receipt': receipt, - 'remote_exit_observed': True, 'heartbeat_stopped': True, 'seconds': time.monotonic() - stopped_at} - app.close() - time.sleep(2) - app = start('after-cancel') - assert app.request('thread/resume', {'threadId': thread, 'approvalPolicy': 'never', 'sandbox': 'danger-full-access'})['thread']['id'] == thread - turn = app.turn(thread, 'Placement check. Use the native shell to read retained.txt, then state the original memory word briefly.', selection) - assert app.completed(turn)['status'] == 'completed' - assert memory in json.dumps(completed_items(app, turn, 'agentMessage')), 'history missing after targeted cancellation' - assert any('remote-file-content' in c.get('aggregatedOutput', '') for c in completed_items(app, turn, 'commandExecution')), 'retained file was not read after cancellation' - report['after_cancel_turn'] = turn - app.close() - time.sleep(2) - default_policy = config.replace('inherit = "core"\nignore_default_excludes = false', 'inherit = "all"\nignore_default_excludes = true') - (root / 'history/config.toml').write_text(default_policy) - app = start('default-policy') - baseline = app.request('thread/start', params)['thread']['id'] - turn = app.turn(baseline, 'Run the exact command `./credentials.sh` once with the native shell. It records variable names only. Do not print any environment values. Reply done.', selection) - assert app.completed(turn)['status'] == 'completed' - names = (workspace / 'credential-names').read_text().splitlines() - assert names == ['CODEX_API_KEY'], 'default-policy exposure differs; inspect recorded names without values' - report['default_policy_visible_credential_names'] = names - app.close() - time.sleep(2) - (root / 'history/config.toml').write_text(config) - invalid_env = dict(env, CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN=uuid.uuid4().hex) - app = start('invalid-auth', invalid_env) - assert app.request('environment/status', {'environmentId': 'local'})['status'] == 'unknown' - try: - app.request('environment/info', {'environmentId': 'remote'}, timeout=30) - except NativeRPCError as error: - assert error.error['code'] == -32603 and '401 Unauthorized' in error.error['message'] - report['invalid_authorization_rejected'] = True - else: - raise AssertionError('invalid registry authorization accepted') - assert not Path(remote).exists() - assert not list((root / 'harness').glob('*.cwd')) - assert not (workspace / 'credential-failure').exists() - report['harness_local_path_not_created'] = True - report['status'] = 'characterized_with_blockers' - report['integration_blockers'] = ['turn/interrupt preserves background execution; typed dispatch must retain Turn ownership and apply native targeted termination where required', 'typed dispatch credential lifetime/readiness/public lifecycle are not implemented'] - finally: - for app in apps: - app.close() - with (root / 'executor.stderr').open('wb') as log: - subprocess.run(['docker', 'logs', container], stdout=log, stderr=log, timeout=10) - subprocess.run(['docker', 'rm', '-f', container], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) - if report.get('status') != 'characterized_with_blockers': - report['status'] = 'failed' - persist() - - -if __name__ == '__main__': - try: - main() - except BaseException: - import traceback - (Path(os.environ['PARSAR_PLACEMENT_ROOT']) / 'failure.txt').write_text(traceback.format_exc()) - raise diff --git a/services/agents-api/tests/native/raw_files/.gitattributes b/services/agents-api/tests/native/raw_files/.gitattributes deleted file mode 100644 index 48fe5701e..000000000 --- a/services/agents-api/tests/native/raw_files/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -client-dependency.patch whitespace=-blank-at-eol diff --git a/services/agents-api/tests/native/raw_files/README.md b/services/agents-api/tests/native/raw_files/README.md deleted file mode 100644 index 87cab141f..000000000 --- a/services/agents-api/tests/native/raw_files/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# Raw remote Files and history qualification - -This opt-in fixture uses the pinned upstream `RemoteAppServerClient` over a private -Unix socket and the [qualified manager hook](../raw_manager/README.md). Typed Files -and native execution share the stock-built EnvironmentManager and one authorized -registry/Noise pair. The socket is a local control connection, separate from that -executor pair. No production daemon path or public Files API is enabled. - -## Prepare and build - -Use the Linux/toolchain/native-resource prerequisites in the manager qualification. -The shared preparation command exports the exact native commit and verifies both -the manager patch and the fixture-only client dependency patch. The latter adds -one dev-dependency/lock edge to an existing exact-pin workspace crate; third-party -packages and versions remain unchanged. The original workspace-version-only lock -normalization is recorded separately. All hashes are in `source.json`. - -From the Parsar worktree, with an absolute `NATIVE_SOURCE` Git checkout path: - -```sh -export RAW_FILES_ROOT="$HOME/.parsar/raw-files-qualification" -python3 services/agents-api/tests/native/raw_manager/prepare.py \ - --manifest services/agents-api/tests/native/raw_files/source.json \ - --source "$NATIVE_SOURCE" --output "$RAW_FILES_ROOT/source" -mkdir -p "$RAW_FILES_ROOT/state" -export TMPDIR="$RAW_FILES_ROOT/state" -export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" -export CARGO_TARGET_DIR="$HOME/.parsar/cache/raw-files-target" -export RUSTUP_TOOLCHAIN=1.95 -export CARGO_PROFILE_DEV_DEBUG=0 -cd "$RAW_FILES_ROOT/source/codex-rs" -cargo build --locked -p codex-app-server \ - --example parsar_raw_files_probe --example parsar_shared_files_probe -cargo clippy --locked -p codex-app-server \ - --example parsar_raw_files_probe --example parsar_shared_files_probe -- -D warnings -rustfmt --check --edition 2024 app-server/examples/parsar_raw_files_probe.rs \ - app-server/examples/parsar_shared_files_probe.rs -``` - -Use a new output directory. Keep `preparation.json`, the compiled probe and matching -native helper/launcher hashes, toolchain, command outcomes and failed evidence. -Run the required repository `make check` separately; native builds and model calls -are explicit opt-in acceptance checks. - -## Real-provider acceptance - -Supply the database, native helper/launcher, pinned executor image, private proof -root and real model key prerequisites in the [native guide](../README.md#shared-native-filesystem-owner). -Point `PARSAR_RAW_FILES_PROBE` at the raw example. From the Parsar worktree: - -```sh -go test ./services/agents-api/internal/store \ - -run '^TestNativeRawEnvironmentFiles$' -count=1 -v -timeout=12m -``` - -The outer Go fixture owns the actual leased database, registry, separate executor -and harness credentials, executor container and independently observed workspace. -It rejects proof directories outside the caller's canonical `~/.parsar` before -creating state. It retains the original caller HOME while giving the native probe -an isolated HOME. Use a short private state/socket path; Unix socket path limits -still apply. A phase uses one native owner and one authenticated pair. - -Both first/fresh phases check 128 KiB binary bytes/hash and metadata/listing while -idle and during a real native command, exact remote effects and observed command -output/status, then fresh-process native history with retained files and a -prompt-only random value. The legacy in-process fixture remains available through -`TestNativeSharedEnvironmentFiles` and `PARSAR_SHARED_FILES_PROBE`; run it with the -prepared legacy example when shared fixture code changes. - -Both fixtures also read a retained 2 MiB + 37-byte binary through the native -same-manager `read_file_stream`, including a 4096-byte prefix and an empty file. -The checks run while idle, during execution and after cold continuation, preserving -the original whole-file, metadata and execution assertions. The retained result is -bounded, but the native stream may fetch a complete 1 MiB chunk for a short prefix. -Early drop schedules native close; neither it nor EOF proves a close receipt or -settlement of all underlying I/O. These checks do not qualify caller detachment, -path confinement or snapshot consistency for a production Files interface. - -The dedicated cancellation workflow preserves that ordinary regression and adds a -separate `first -> cancel -> fresh` run with the same raw example: - -```sh -go test ./services/agents-api/internal/store \ - -run '^TestNativeRawEnvironmentFilesCancellation$' -count=1 -v -timeout=12m -``` - -Go independently observes the active command before allowing native interruption. -The fixture distinguishes its acknowledgement from the observed interrupted Turn, -nullable command completion, targeted background-terminal termination and command -effects. A termination target must match the current Turn's observed native item -and process identifiers; an OS PID never selects a native target. The owner stays -alive while typed Files and the independently observed command exit are checked. -After a fresh process resumes, native Turn listing must retain the interrupted -Turn, and typed Files must retain the post-cancel marker and binary hash before new -execution. Completed command counts prove old work was not rerun. Keep native -typed bodies and actual unknown fields; do not synthesize an exit code or final -answer for cancellation. - -## Limits - -The maintained remote client has an internal unbounded event channel and uses its -pinned typed notification parser. The fixture's finite event/byte assertions do not -bound that queue or prove preservation of unknown notifications. This workflow -qualifies private Files/execution/history composition, not production backpressure, -complete output, public Files paths/references/pagination, idle ownership or caller -and tenant authorization. Client shutdown alone does not stop the runner; the -fixture must use native runner shutdown and join it before claiming completion. - -Only the dedicated three-phase workflow qualifies this cancellation composition; -the ordinary first/fresh checks do not exercise it. Command PID exit and a stable -heartbeat are bounded observations, not proof that all descendants are gone. -Production adoption also requires exact Environment/device/generation ownership, bounded capacity and -lifetime, history retention and stale-write retirement. Never replay an unknown -mutation. Readiness-gated command output does not fix the recorded native early-output -limitation. Real model calls are necessary for this acceptance; no-model or synthetic -checks cannot substitute for it. diff --git a/services/agents-api/tests/native/raw_files/client-dependency.patch b/services/agents-api/tests/native/raw_files/client-dependency.patch deleted file mode 100644 index fbcc00922..000000000 --- a/services/agents-api/tests/native/raw_files/client-dependency.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- a/codex-rs/app-server/Cargo.toml -+++ b/codex-rs/app-server/Cargo.toml -@@ -118,6 +118,7 @@ - codex-windows-sandbox = { workspace = true } - - [dev-dependencies] -+codex-app-server-client = { workspace = true } - app_test_support = { workspace = true } - axum = { workspace = true, default-features = false, features = [ - "http1", ---- a/codex-rs/Cargo.lock -+++ b/codex-rs/Cargo.lock -@@ -2032,6 +2032,7 @@ - "clap", - "codex-agent-extension", - "codex-analytics", -+ "codex-app-server-client", - "codex-app-server-protocol", - "codex-app-server-transport", - "codex-arg0", diff --git a/services/agents-api/tests/native/raw_files/source.json b/services/agents-api/tests/native/raw_files/source.json deleted file mode 100644 index e12968564..000000000 --- a/services/agents-api/tests/native/raw_files/source.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "repository": "https://github.com/openai/codex", - "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", - "native_version": "0.153.4", - "rust_toolchain": "1.95.0", - "scope": "private raw remote Files/execution/cancellation/cold-history qualification; no production selection", - "patch": { - "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", - "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" - }, - "cargo_lock": { - "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", - "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", - "workspace_packages": 149 - }, - "fixtures": [ - { - "source": "../shared_files_probe.rs", - "target": "codex-rs/app-server/examples/parsar_shared_files_probe.rs" - }, - { - "source": "../raw_files_probe.rs", - "target": "codex-rs/app-server/examples/parsar_raw_files_probe.rs" - }, - { - "source": "../shared_files/files.rs", - "target": "codex-rs/app-server/examples/shared_files/files.rs" - }, - { - "source": "../shared_files/runtime.rs", - "target": "codex-rs/app-server/examples/shared_files/runtime.rs" - }, - { - "source": "../shared_files/raw_runtime.rs", - "target": "codex-rs/app-server/examples/shared_files/raw_runtime.rs" - }, - { - "source": "../shared_files/probe.rs", - "target": "codex-rs/app-server/examples/shared_files/probe.rs" - }, - { - "source": "../shared_files/observations.rs", - "target": "codex-rs/app-server/examples/shared_files/observations.rs" - }, - { - "source": "../shared_files/configuration.rs", - "target": "codex-rs/app-server/examples/shared_files/configuration.rs" - }, - { - "source": "../shared_files/cancellation.rs", - "target": "codex-rs/app-server/examples/shared_files/cancellation.rs" - } - ], - "fixture_patch": { - "file": "client-dependency.patch", - "sha256": "76df7fcb970ca67bd1f40b2d98e5a2f8ec24564f2e772a69a7e9e9b7ad7f2cc9", - "scope": "one dev-dependency edge to the existing exact-pin workspace client; no third-party changes", - "prepared_files": { - "codex-rs/app-server/Cargo.toml": "c0d1cbed4ab6256ba28a3bbfaad7b8217ae928dce29b8299bd725027c63e350e", - "codex-rs/Cargo.lock": "25dbeba0fe924e6501168dd8b54670a6ec9d0639cd3ad0ccbb5828f1d4b60fa6" - } - } -} diff --git a/services/agents-api/tests/native/raw_files_probe.rs b/services/agents-api/tests/native/raw_files_probe.rs deleted file mode 100644 index b421db6fc..000000000 --- a/services/agents-api/tests/native/raw_files_probe.rs +++ /dev/null @@ -1,16 +0,0 @@ -#[path = "shared_files/cancellation.rs"] -mod cancellation; -#[path = "shared_files/configuration.rs"] -mod configuration; -#[path = "shared_files/files.rs"] -mod files; -#[path = "shared_files/observations.rs"] -mod observations; -#[path = "shared_files/probe.rs"] -mod probe; -#[path = "shared_files/raw_runtime.rs"] -mod runtime; - -fn main() -> std::process::ExitCode { - probe::main(true) -} diff --git a/services/agents-api/tests/native/raw_manager/.gitattributes b/services/agents-api/tests/native/raw_manager/.gitattributes deleted file mode 100644 index 94f0862b4..000000000 --- a/services/agents-api/tests/native/raw_manager/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -manager-exposure.patch whitespace=-blank-at-eol diff --git a/services/agents-api/tests/native/raw_manager/README.md b/services/agents-api/tests/native/raw_manager/README.md deleted file mode 100644 index e557e4a06..000000000 --- a/services/agents-api/tests/native/raw_manager/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Raw app-server manager qualification - -This private experiment exposes the `EnvironmentManager` created by the stock -Codex raw runner. Native requests and typed filesystem operations can therefore -use the same manager without adopting the in-process notification queue. It adds -one explicit embedding entrypoint; the ordinary entrypoint retains its behavior. -The patch is a local dependency experiment, not an available upstream API or a -production runtime selection. - -`source.json` pins Codex 0.153.4, commit -`3d2ee51ca2d5db578f328aa75e20aa22c0197c9a`, the patch hash, Rust 1.95.0 and -the build lock overlay. The upstream lock records 149 workspace packages as -`0.0.0` while its manifest uses `0.153.4`. Preparation changes only those version -entries and checks the exact original/result hashes; no dependency resolver or -third-party update is involved. Keep this qualification separate from the public -Agents API protocol pin and the existing production native installation. - -## Prepare and run - -Use Linux with Python 3.10+, Git, tar, Rust 1.95.0, rustfmt, Clippy, a C toolchain, -pkg-config and OpenSSL development headers. Supply an existing official Codex Git -checkout containing the exact commit and its matching native executable/resources. -The source export uses the named commit, ignoring any local checkout changes. - -From the Parsar worktree, set `NATIVE_SOURCE` and `CODEX_BINARY` to absolute paths: - -```sh -export RAW_MANAGER_ROOT="$HOME/.parsar/raw-manager-qualification" -python3 services/agents-api/tests/native/raw_manager/prepare.py \ - --source "$NATIVE_SOURCE" --output "$RAW_MANAGER_ROOT/source" -mkdir -p "$RAW_MANAGER_ROOT/state" -export TMPDIR="$RAW_MANAGER_ROOT/state" -export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" -export CARGO_TARGET_DIR="$HOME/.parsar/cache/raw-manager-target" -export RUSTUP_TOOLCHAIN=1.95 -export CARGO_PROFILE_DEV_DEBUG=0 -rustc --version # The installed toolchain must report 1.95.0. -cd "$RAW_MANAGER_ROOT/source/codex-rs" -cargo build --locked -p codex-app-server --example parsar_raw_manager_probe -cargo test --locked -p codex-app-server --example parsar_raw_manager_probe -cargo clippy --locked -p codex-app-server --example parsar_raw_manager_probe -- -D warnings -rustfmt --check --edition 2024 app-server/examples/parsar_raw_manager_probe.rs -cargo test --locked -p codex-app-server --lib transport::tests -for mode in smoke receiver-dropped startup-failure late-startup-failure; do - "$CARGO_TARGET_DIR/debug/examples/parsar_raw_manager_probe" \ - "$mode" "$CODEX_BINARY" "$RAW_MANAGER_ROOT/state" -done -``` - -Use a new output directory for each prepared source. Failed preparation leaves -its partial directory for inspection and never overwrites an existing tree. -Retain `preparation.json`, command outputs/exit codes, toolchain versions and the -compiled probe/helper SHA-256 values with the acceptance evidence. A mismatched -patch or upstream lock fails preparation; there is no fallback to another pin. -Native tests and build are opt-in, separate from the repository's `make check`. - -## What this establishes - -The deterministic fixture starts the actual raw stdio runner, performs its native -handshake and adds a fresh Environment through native RPC. Looking up that new ID -through the published handle and using its typed filesystem checks shared manager -identity; a same-disk read/write alone would not. Failure modes exercise a dropped -manager receiver and startup failure. Existing native transport tests retain the -stock backpressure/disconnect regression coverage. - -The fixture resolves proof paths within the caller's `~/.parsar` before creating -state. Its isolated child HOME retains the original caller context for this check; -the path test rejects misleading components, parent traversal and symlink escapes. - -Publication means that a manager handle exists. It does not establish completed -initialization, remote readiness, caller authorization or revocation. An `Arc` may -outlive the runner, so a future owner must supervise failure and release it. - -There are no model calls in this seam qualification. Real remote Files plus model -execution/cancellation/cold-history composition remain required before adoption. -Idle ownership, tenant/generation checks, workspace confinement, unknown write -outcomes and public Files paths/references/pagination remain separate work. This -does not fix or validate every native event queue, and it does not change the -existing shared-manager probe's recorded limitations. Production adoption must -explicitly own patch maintenance and remove the patch when a suitable maintained -upstream entrypoint is selected and verified. diff --git a/services/agents-api/tests/native/raw_manager/owner.rs b/services/agents-api/tests/native/raw_manager/owner.rs deleted file mode 100644 index 6160adc0a..000000000 --- a/services/agents-api/tests/native/raw_manager/owner.rs +++ /dev/null @@ -1,159 +0,0 @@ -use anyhow::{Context, Result, ensure}; -use codex_app_server::{ - AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, - PluginStartupTasks, RemoteControlStartupMode, - run_main_with_transport_options_and_environment_manager, -}; -use codex_arg0::Arg0DispatchPaths; -use codex_config::LoaderOverrides; -use codex_exec_server::EnvironmentManager; -use codex_protocol::protocol::SessionSource; -use codex_utils_cli::CliConfigOverrides; -use codex_utils_path_uri::PathUri; -use std::io::ErrorKind; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::oneshot; - -pub const ENVIRONMENT_ID: &str = "raw-manager-smoke"; -pub const CONTENT: &[u8] = b"typed Files through the stock raw runner\n"; - -async fn run( - native: &Path, - transport: AppServerTransport, - invalid: bool, - sender: oneshot::Sender>, -) -> std::io::Result<()> { - let mut loader = LoaderOverrides::without_managed_config_for_tests(); - loader.ignore_user_config = true; - loader.ignore_project_config = true; - run_main_with_transport_options_and_environment_manager( - Arg0DispatchPaths { - codex_self_exe: Some(native.to_path_buf()), - ..Default::default() - }, - CliConfigOverrides { - raw_overrides: if invalid { - vec!["missing-equals".into()] - } else { - vec![] - }, - }, - loader, - true, - false, - transport, - SessionSource::Exec, - AppServerWebsocketAuthSettings::default(), - AppServerRuntimeOptions { - plugin_startup_tasks: PluginStartupTasks::Skip, - remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, - install_shutdown_signal_handler: false, - ..Default::default() - }, - sender, - ) - .await -} - -pub async fn serve(native: &Path, root: &Path) -> Result<()> { - let (sender, receiver) = oneshot::channel::>(); - let files = async { - let manager = receiver.await.context("manager was not published")?; - ensure!( - manager.try_local_environment().is_none(), - "local fallback is configured" - ); - // This ID can appear only after the controller's initialized raw RPC. - let environment = tokio::time::timeout(Duration::from_secs(20), async { - loop { - if let Some(environment) = manager.get_environment(ENVIRONMENT_ID) { - break environment; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .context("raw environment/add did not reach the published manager")?; - ensure!( - environment.is_remote(), - "raw-added environment is not remote" - ); - let filesystem = environment.get_filesystem(); - let path = PathUri::from_host_native_path(root.join("remote-file.txt"))?; - filesystem - .write_file(&path, CONTENT.to_vec(), Default::default(), None) - .await?; - let bytes = filesystem - .read_file(&path, Default::default(), None) - .await?; - ensure!( - bytes == CONTENT, - "typed remote read returned different bytes" - ); - tokio::fs::write( - root.join("typed-files.pending"), - b"{\"shared_manager\":true,\"typed_files\":true}\n", - ) - .await?; - tokio::fs::rename( - root.join("typed-files.pending"), - root.join("typed-files.json"), - ) - .await?; - Ok::<_, anyhow::Error>(()) - }; - tokio::try_join!( - async { - run(native, AppServerTransport::Stdio, false, sender) - .await - .map_err(Into::into) - }, - files - )?; - Ok(()) -} - -pub async fn failure(native: &Path, mode: &str) -> Result<()> { - let (sender, mut receiver) = oneshot::channel::>(); - if mode == "receiver-dropped" { - drop(receiver); - let error = run(native, AppServerTransport::Stdio, false, sender) - .await - .err() - .context("dropped receiver must fail startup")?; - ensure!( - error.kind() == ErrorKind::BrokenPipe, - "wrong dropped-receiver error: {error}" - ); - } else if mode == "startup-failure" { - ensure!( - run(native, AppServerTransport::Stdio, true, sender) - .await - .is_err() - ); - ensure!( - matches!( - receiver.try_recv(), - Err(oneshot::error::TryRecvError::Closed) - ), - "failed configuration published a manager" - ); - } else { - // A bind failure occurs after handle publication, proving it is not readiness. - let occupied = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let transport = format!("ws://{}", occupied.local_addr()?).parse()?; - ensure!(run(native, transport, false, sender).await.is_err()); - let manager = receiver - .try_recv() - .context("expected publication before bind failure")?; - let weak = Arc::downgrade(&manager); - drop(manager); - ensure!( - weak.upgrade().is_none(), - "failed startup retained the manager" - ); - } - Ok(()) -} diff --git a/services/agents-api/tests/native/raw_manager/prepare.py b/services/agents-api/tests/native/raw_manager/prepare.py deleted file mode 100644 index dfce6b48c..000000000 --- a/services/agents-api/tests/native/raw_manager/prepare.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare an exact-pin, explicitly patched native qualification source tree.""" - -import argparse -import hashlib -import json -import subprocess -from pathlib import Path - - -def sha(data): - return hashlib.sha256(data).hexdigest() - - -def prepare(source, output, manifest_file=None): - manifest_file = manifest_file or Path(__file__).resolve().with_name("source.json") - here = manifest_file.parent - manifest_bytes = manifest_file.read_bytes() - manifest = json.loads(manifest_bytes) - patch = here / manifest["patch"]["file"] - patch_bytes = patch.read_bytes() - if sha(patch_bytes) != manifest["patch"]["sha256"]: - raise ValueError("native patch does not match source.json") - patches = [patch] - fixture_patch = manifest.get("fixture_patch") - if fixture_patch: - fixture = here / fixture_patch["file"] - if sha(fixture.read_bytes()) != fixture_patch["sha256"]: - raise ValueError("fixture dependency patch does not match source.json") - patches.append(fixture) - revision = manifest["revision"] - resolved = subprocess.check_output( - ["git", "-C", str(source), "rev-parse", revision + "^{commit}"], text=True - ).strip() - if resolved != revision: - raise ValueError("native source revision differs") - runtime = (Path.home() / ".parsar").resolve() - if not output.is_relative_to(runtime) or output == runtime: - raise ValueError("output must be a new directory below ~/.parsar") - output.mkdir(parents=True, exist_ok=False) - # Export the named commit, never the caller's potentially modified checkout. - with subprocess.Popen( - ["git", "-C", str(source), "archive", "--format=tar", revision], - stdout=subprocess.PIPE, - ) as archive: - try: - subprocess.run(["tar", "-xf", "-", "-C", str(output)], stdin=archive.stdout, check=True) - finally: - archive.stdout.close() - if archive.wait() != 0: - raise RuntimeError("native source export failed") - - lock = output / "codex-rs/Cargo.lock" - original = lock.read_bytes() - overlay = manifest["cargo_lock"] - if sha(original) != overlay["original_sha256"]: - raise ValueError("unexpected upstream Cargo.lock") - parts = original.split(b"[[package]]") - changed = 0 - for index, part in enumerate(parts[1:], 1): - if b'\nsource = ' not in part and b'\nversion = "0.0.0"\n' in part: - parts[index] = part.replace(b'\nversion = "0.0.0"\n', b'\nversion = "0.153.4"\n', 1) - changed += 1 - normalized = b"[[package]]".join(parts) - if changed != overlay["workspace_packages"] or sha(normalized) != overlay["normalized_sha256"]: - raise ValueError("workspace-only lock normalization differs") - lock.write_bytes(normalized) - for item in patches: - subprocess.run(["git", "apply", "--check", str(item)], cwd=output, check=True) - subprocess.run(["git", "apply", str(item)], cwd=output, check=True) - if fixture_patch: - for name, expected in fixture_patch["prepared_files"].items(): - if sha((output / name).read_bytes()) != expected: - raise ValueError("fixture dependency source differs: " + name) - - fixtures = {} - for item in manifest["fixtures"]: - data = (here / item["source"]).read_bytes() - target = output / item["target"] - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) - fixtures[item["target"]] = sha(data) - record = { - "revision": revision, - "manifest_sha256": sha(manifest_bytes), - "patch_sha256": sha(patch_bytes), - "cargo_lock": overlay, - "fixtures": fixtures, - } - if fixture_patch: - record["fixture_patch"] = fixture_patch - (output / "preparation.json").write_text(json.dumps(record, indent=2) + "\n") - print(output) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--source", required=True, type=Path, help="existing native Git checkout") - parser.add_argument("--output", required=True, type=Path, help="new directory under ~/.parsar") - parser.add_argument("--manifest", type=Path, help="explicit native qualification manifest") - args = parser.parse_args() - source, output = args.source.expanduser(), args.output.expanduser() - if not source.is_absolute() or not output.is_absolute(): - parser.error("source and output must be absolute paths") - manifest_file = args.manifest.expanduser().resolve() if args.manifest else None - prepare(source.resolve(), output.resolve(), manifest_file) diff --git a/services/agents-api/tests/native/raw_manager/probe.rs b/services/agents-api/tests/native/raw_manager/probe.rs deleted file mode 100644 index 19dfcbaf9..000000000 --- a/services/agents-api/tests/native/raw_manager/probe.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Opt-in no-model qualification of the additive raw-runner manager hook. - -#[path = "raw_manager/owner.rs"] -mod owner; - -use anyhow::{Context, Result, bail, ensure}; -use codex_exec_server::ExecServerRuntimePaths; -use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; -use serde_json::{Value, json}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; -use tokio::process::{Child, ChildStdout, Command}; - -const DEADLINE: Duration = Duration::from_secs(30); -// Match pinned arg0/async-utils stack sizing for large native futures. -const NATIVE_STACK_BYTES: usize = 16 * 1024 * 1024; -const CALLER_HOME_ENV: &str = "PARSAR_RAW_MANAGER_CALLER_HOME"; - -fn main() -> Result<()> { - std::thread::Builder::new() - .name("raw-manager-main".into()) - .stack_size(NATIVE_STACK_BYTES) - .spawn(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .worker_threads(4) - .thread_stack_size(NATIVE_STACK_BYTES) - .build()? - .block_on(run()) - })? - .join() - .map_err(|_| anyhow::anyhow!("native main thread panicked"))? -} - -async fn run() -> Result<()> { - let args: Vec = std::env::args().collect(); - ensure!( - args.len() == 4, - "usage: probe MODE NATIVE_BINARY PROOF_DIRECTORY" - ); - let native = Path::new(&args[2]); - let root = Path::new(&args[3]); - ensure!( - native.is_absolute() && root.is_absolute(), - "absolute paths are required" - ); - let home_variable = match args[1].as_str() { - "owner" - | "child-receiver-dropped" - | "child-startup-failure" - | "child-late-startup-failure" => CALLER_HOME_ENV, - _ => "HOME", - }; - let caller_home = - PathBuf::from(std::env::var_os(home_variable).context("caller HOME is missing")?); - let root = proof_root(root, &caller_home)?; - match args[1].as_str() { - "owner" => owner::serve(native, &root).await, - "child-receiver-dropped" | "child-startup-failure" | "child-late-startup-failure" => { - owner::failure(native, args[1].trim_start_matches("child-")).await - } - "smoke" | "receiver-dropped" | "startup-failure" | "late-startup-failure" => { - let root = tempfile::Builder::new() - .prefix("raw-manager-") - .tempdir_in(&root)? - .keep(); - let result = tokio::time::timeout(DEADLINE, qualify(&args[1], native, &root)).await; - println!( - "{}", - json!({"case":args[1],"evidence":root,"passed":matches!(&result, Ok(Ok(())))}) - ); - result.context("qualification timed out")? - } - _ => bail!("unknown qualification mode"), - } -} - -fn proof_root(root: &Path, caller_home: &Path) -> Result { - ensure!(caller_home.is_absolute(), "caller HOME must be absolute"); - let expected = caller_home - .join(".parsar") - .canonicalize() - .context("resolve caller state directory")?; - let actual = root.canonicalize().context("resolve proof directory")?; - ensure!( - actual.starts_with(expected), - "proof directory must resolve under caller ~/.parsar" - ); - Ok(actual) -} - -fn child(mode: &str, native: &Path, root: &Path) -> Result { - let home = root.join("codex"); - std::fs::create_dir_all(&home)?; - let stderr = std::fs::File::create(root.join("owner.stderr"))?; - Command::new(std::env::current_exe()?) - .args([ - mode, - native.to_str().context("native path is not UTF-8")?, - root.to_str().context("proof path is not UTF-8")?, - ]) - .env_clear() - .env("HOME", root) - // Retained fixture context, not authorization; native HOME stays isolated. - .env( - CALLER_HOME_ENV, - std::env::var_os("HOME").context("caller HOME is missing")?, - ) - .env("CODEX_HOME", home) - .env("TMPDIR", root) - .env("PATH", "/usr/bin:/bin") - .env("CODEX_EXEC_SERVER_URL", "none") - .env("RUST_LOG", "warn") - .current_dir(root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(stderr) - .kill_on_drop(true) - .spawn() - .context("launch raw-runner owner") -} - -async fn response(lines: &mut Lines>, id: i64) -> Result { - while let Some(line) = lines.next_line().await? { - let value: Value = serde_json::from_str(&line).context("native stdout was not JSON")?; - if value["id"] == id { - ensure!( - value.get("error").is_none(), - "native request failed: {value}" - ); - return Ok(value["result"].clone()); - } - ensure!( - value.get("id").is_none(), - "unexpected native request/response: {value}" - ); - } - bail!("owner exited before response {id}") -} - -async fn qualify(mode: &str, native: &Path, root: &Path) -> Result<()> { - if mode != "smoke" { - let mut child = child(&format!("child-{mode}"), native, root)?; - ensure!( - child.wait().await?.success(), - "failure scenario did not meet its assertions" - ); - return Ok(()); - } - - // Use the native exec-server, with the unchanged pinned executable for helpers. - let reservation = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let endpoint = format!("ws://{}", reservation.local_addr()?); - drop(reservation); - let server_endpoint = endpoint.clone(); - let paths = ExecServerRuntimePaths::new(PathBuf::from(native), None)?; - let executor = tokio::spawn(async move { - codex_exec_server::run_main( - &server_endpoint, - paths, - HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - ) - .await - }); - let executor = tokio_util::task::AbortOnDropHandle::new(executor); - - let mut owner = child("owner", native, root)?; - let mut input = owner.stdin.take().context("missing owner stdin")?; - let mut lines = BufReader::new(owner.stdout.take().context("missing owner stdout")?).lines(); - input.write_all(format!("{}\n", json!({"id":1,"method":"initialize","params":{"clientInfo":{"name":"parsar_raw_manager_probe","version":"1"},"capabilities":{"experimentalApi":true}}})).as_bytes()).await?; - let initialized = response(&mut lines, 1).await?; - ensure!(initialized.is_object(), "missing stock initialize response"); - input.write_all(b"{\"method\":\"initialized\"}\n").await?; - input.write_all(format!("{}\n", json!({"id":2,"method":"environment/add","params":{"environmentId":owner::ENVIRONMENT_ID,"execServerUrl":endpoint}})).as_bytes()).await?; - response(&mut lines, 2).await?; - loop { - if tokio::fs::try_exists(root.join("typed-files.json")).await? { - break; - } - ensure!( - owner.try_wait()?.is_none(), - "owner exited before typed Files completed" - ); - tokio::time::sleep(Duration::from_millis(10)).await; - } - let evidence: Value = - serde_json::from_slice(&tokio::fs::read(root.join("typed-files.json")).await?)?; - ensure!(evidence["shared_manager"] == true && evidence["typed_files"] == true); - ensure!(tokio::fs::read(root.join("remote-file.txt")).await? == owner::CONTENT); - input.write_all(format!("{}\n", json!({"id":3,"method":"environment/status","params":{"environmentId":owner::ENVIRONMENT_ID}})).as_bytes()).await?; - ensure!( - response(&mut lines, 3).await?["status"] == "ready", - "raw manager lost its environment" - ); - drop(input); - while lines.next_line().await?.is_some() {} - ensure!( - owner.wait().await?.success(), - "raw owner failed during EOF shutdown" - ); - drop(executor); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn proof_root_rejects_component_traversal_and_symlink_escapes() -> Result<()> { - let caller_home = PathBuf::from(std::env::var_os("HOME").context("HOME is missing")?); - let task_root = - PathBuf::from(std::env::var_os("TMPDIR").context("task TMPDIR is missing")?); - let task_root = proof_root(&task_root, &caller_home)?; - let fixture = tempfile::Builder::new() - .prefix("raw-manager-path-test-") - .tempdir_in(task_root)?; - let home = fixture.path().join("caller"); - let valid = home.join(".parsar/proof"); - let outside = fixture.path().join("outside/.parsar"); - std::fs::create_dir_all(&valid)?; - std::fs::create_dir_all(&outside)?; - ensure!(proof_root(&valid, &home)? == valid.canonicalize()?); - ensure!( - proof_root(&outside, &home).is_err(), - "a .parsar component is insufficient" - ); - let traversal = home.join(".parsar/../../outside/.parsar"); - ensure!( - proof_root(&traversal, &home).is_err(), - "parent traversal escaped the state root" - ); - #[cfg(unix)] - { - let link = home.join(".parsar/link"); - std::os::unix::fs::symlink(&outside, &link)?; - ensure!( - proof_root(&link, &home).is_err(), - "symlink escaped the state root" - ); - } - Ok(()) - } -} diff --git a/services/agents-api/tests/native/raw_manager/source.json b/services/agents-api/tests/native/raw_manager/source.json deleted file mode 100644 index ba22f5935..000000000 --- a/services/agents-api/tests/native/raw_manager/source.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "repository": "https://github.com/openai/codex", - "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", - "native_version": "0.153.4", - "rust_toolchain": "1.95.0", - "scope": "private no-model raw-runner manager qualification; no production selection", - "patch": { - "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", - "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" - }, - "cargo_lock": { - "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", - "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", - "workspace_packages": 149 - }, - "fixtures": [ - { - "source": "probe.rs", - "target": "codex-rs/app-server/examples/parsar_raw_manager_probe.rs" - }, - { - "source": "owner.rs", - "target": "codex-rs/app-server/examples/raw_manager/owner.rs" - } - ] -} diff --git a/services/agents-api/tests/native/relay_probe.rs b/services/agents-api/tests/native/relay_probe.rs deleted file mode 100644 index 2823f270b..000000000 --- a/services/agents-api/tests/native/relay_probe.rs +++ /dev/null @@ -1,294 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::{Context, Result, ensure}; -use codex_exec_server::{ - EnvironmentConnectionState, EnvironmentManager, EnvironmentObservedStatus, ExecOutputStream, - ExecParams, ExecProcess, FileSystemSandboxContext, ProcessId, ReadFileOptions, - WriteFileOptions, -}; -use codex_http_client::{HttpClientFactory, OutboundProxyPolicy}; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::{ - FileSystemAccessMode, FileSystemPath, FileSystemSandboxEntry, FileSystemSandboxPolicy, - FileSystemSpecialPath, NetworkSandboxPolicy, -}; -use codex_utils_path_uri::PathUri; -use tokio::time::timeout; - -const TIMEOUT: Duration = Duration::from_secs(40); - -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() -> Result<()> { - timeout(Duration::from_secs(100), run()).await??; - Ok(()) -} - -async fn run() -> Result<()> { - let root = PathBuf::from(std::env::var("PARSAR_NATIVE_ENV_PROOF")?); - let phase = std::env::args().nth(1).context("phase required")?; - let manager = EnvironmentManager::from_env( - None, - HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - ) - .await?; - ensure!( - manager.try_local_environment().is_none(), - "local fallback configured" - ); - let environment = manager - .default_environment() - .context("remote environment")?; - ensure!(environment.is_remote(), "expected remote backend"); - timeout(TIMEOUT, environment.wait_until_ready()).await??; - let cwd = PathUri::from_host_native_path(root.join("workspace"))?; - let file = PathUri::from_host_native_path(root.join("workspace/large.bin"))?; - let marker_path = root.join("workspace/start-marker.txt"); - let marker = PathUri::from_host_native_path(&marker_path)?; - let contents: Vec = (0..128 * 1024).map(|index| (index % 251) as u8).collect(); - let fs = environment.get_filesystem(); - if phase == "helpers" { - let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( - FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - FileSystemAccessMode::Read, - )]); - let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( - PermissionProfile::from_runtime_permissions( - &read_only, - NetworkSandboxPolicy::Restricted, - ), - cwd.clone(), - ); - ensure!( - fs.read_file(&file, ReadFileOptions::default(), Some(&sandbox)) - .await? - == contents, - "sandboxed native file read mismatch" - ); - ensure!( - fs.write_file( - &file, - b"must-not-write".to_vec(), - WriteFileOptions::default(), - Some(&sandbox) - ) - .await - .is_err(), - "read-only native helper permitted a write" - ); - let mut command = params("native-helpers", &cwd, vec![ - "/bin/sh".into(), "-c".into(), - "printf '%s' \"$0\"; if printf forbidden > sandbox-denied; then exit 42; fi; exit 7".into(), - ]); - command.arg0 = Some("launcher-argv0".into()); - command.sandbox = Some(sandbox); - let process = environment.get_exec_backend().start(command).await?; - let (stdout, _, exit) = read_closed(process.process.as_ref()).await?; - ensure!( - stdout == b"launcher-argv0" && exit == Some(7), - "sandboxed argv0 helper mismatch" - ); - ensure!( - fs.read_file(&file, ReadFileOptions::default(), None) - .await? - == contents, - "denied write changed file" - ); - std::fs::write( - root.join("helpers.json"), - r#"{"native_fs_helper":true,"native_argv0_helper":true,"read_only_enforced":true}"#, - )?; - return Ok(()); - } - if phase == "fresh" { - ensure!( - fs.read_file(&file, ReadFileOptions::default(), None) - .await? - == contents, - "retained file mismatch" - ); - let start = fs - .read_file(&marker, ReadFileOptions::default(), None) - .await?; - ensure!( - String::from_utf8(start)?.lines().count() == 1, - "command repeated" - ); - std::fs::write( - root.join("fresh.json"), - r#"{"fresh_connection":true,"retained_file":true,"single_start":true}"#, - )?; - return Ok(()); - } - ensure!(phase == "first", "unknown phase"); - let exec = environment.get_exec_backend(); - let mut controlled_params = params("retained", &cwd, vec![ - "/bin/sh".into(), "-c".into(), - "printf '%s %s\\n' \"$1\" \"$$\" >> \"$2\"; printf 'READY\\n'; IFS= read -r first; printf 'FIRST:%s\\n' \"$first\"; IFS= read -r second; printf 'SECOND:%s\\n' \"$second\"; exit 9".into(), - "relay".into(), "single-native-start".into(), marker_path.to_str().context("marker path")?.into(), - ]); - controlled_params.pipe_stdin = true; - let controlled = exec.start(controlled_params).await?; - read_until(controlled.process.as_ref(), b"READY\n").await?; - let command = async { - let started = exec - .start(params( - "concurrent", - &cwd, - vec![ - "/bin/sh".into(), - "-c".into(), - "printf native-stdout; printf native-stderr >&2; exit 7".into(), - ], - )) - .await?; - let (stdout, stderr, exit) = read_closed(started.process.as_ref()).await?; - ensure!( - stdout == b"native-stdout" && stderr == b"native-stderr" && exit == Some(7), - "command output mismatch" - ); - Ok::<_, anyhow::Error>(()) - }; - let files = async { - fs.write_file(&file, contents.clone(), WriteFileOptions::default(), None) - .await?; - ensure!( - fs.read_file(&file, ReadFileOptions::default(), None) - .await? - == contents, - "large file mismatch" - ); - Ok::<_, anyhow::Error>(()) - }; - tokio::try_join!(command, files)?; - let sleeper = exec - .start(params( - "terminate", - &cwd, - vec!["/bin/sleep".into(), "60".into()], - )) - .await?; - sleeper.process.terminate().await?; - let (_, _, terminated_exit) = read_closed(sleeper.process.as_ref()).await?; - ensure!(terminated_exit.is_some(), "termination did not settle"); - environment.refresh_connection().await?; - controlled.process.write(b"before-loss\n".to_vec()).await?; - read_until(controlled.process.as_ref(), b"FIRST:before-loss\n").await?; - let mut states = environment - .subscribe_connection_state() - .context("state receiver")?; - ensure!( - *states.borrow_and_update() == EnvironmentConnectionState::Connected, - "not connected before injection" - ); - std::fs::write(root.join("ready-to-disconnect"), b"ready")?; - timeout(TIMEOUT, async { - let mut disconnected = false; - loop { - states.changed().await?; - match *states.borrow_and_update() { - EnvironmentConnectionState::Disconnected => disconnected = true, - EnvironmentConnectionState::Connected if disconnected => { - return Ok::<_, anyhow::Error>(()); - } - EnvironmentConnectionState::Connected => {} - } - } - }) - .await??; - ensure!( - environment.status().await == EnvironmentObservedStatus::Ready, - "recovered status is not ready" - ); - controlled.process.write(b"after-loss\n".to_vec()).await?; - let (stdout, stderr, exit) = read_closed(controlled.process.as_ref()).await?; - ensure!( - stdout == b"READY\nFIRST:before-loss\nSECOND:after-loss\n" - && stderr.is_empty() - && exit == Some(9), - "retained process outcome mismatch" - ); - let start = String::from_utf8( - fs.read_file(&marker, ReadFileOptions::default(), None) - .await?, - )?; - ensure!( - start.lines().count() == 1 && start.starts_with("single-native-start "), - "command start repeated" - ); - std::fs::write( - root.join("first.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "native_remote":true,"large_file_bytes":contents.len(),"concurrent_command":true, - "stdout_stderr":true,"command_exit":7,"termination_exit":terminated_exit, - "same_key_refresh":true,"disconnected_connected":true,"same_process_recovered":true, - "controlled_exit":9,"single_command_start":true,"model_calls":0 - }))?, - )?; - Ok(()) -} - -fn params(id: &str, cwd: &PathUri, argv: Vec) -> ExecParams { - ExecParams { - process_id: ProcessId::from(id), - argv, - cwd: cwd.clone(), - shell_snapshot: None, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: false, - arg0: None, - sandbox: None, - enforce_managed_network: false, - managed_network: None, - network_proxy: None, - } -} - -async fn read_until(process: &dyn ExecProcess, needle: &[u8]) -> Result<()> { - timeout(TIMEOUT, async { - let mut after = None; - let mut output = Vec::new(); - loop { - let result = process.read(after, None, Some(1000)).await?; - ensure!(result.failure.is_none(), "process failed"); - for chunk in result.chunks { - output.extend(chunk.chunk.0); - } - if output.windows(needle.len()).any(|part| part == needle) { - return Ok(()); - } - ensure!(!result.closed, "process closed before checkpoint"); - after = result.next_seq.checked_sub(1); - } - }) - .await? -} - -async fn read_closed(process: &dyn ExecProcess) -> Result<(Vec, Vec, Option)> { - timeout(TIMEOUT, async { - let mut after = None; - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - loop { - let result = process.read(after, None, Some(1000)).await?; - ensure!(result.failure.is_none(), "process failed"); - for chunk in result.chunks { - match chunk.stream { - ExecOutputStream::Stdout => stdout.extend(chunk.chunk.0), - ExecOutputStream::Stderr => stderr.extend(chunk.chunk.0), - ExecOutputStream::Pty => anyhow::bail!("unexpected PTY"), - } - } - if result.closed { - return Ok((stdout, stderr, result.exit_code)); - } - after = result.next_seq.checked_sub(1); - } - }) - .await? -} diff --git a/services/agents-api/tests/native/retirement/README.md b/services/agents-api/tests/native/retirement/README.md deleted file mode 100644 index 5badd0281..000000000 --- a/services/agents-api/tests/native/retirement/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# Native executor retirement qualification - -This opt-in Linux regression distinguishes connection/task shutdown from retirement -of already admitted filesystem work. It is a negative qualification of the pinned -Codex executor, not a production fix or public Files acceptance. - -## Reproduce - -Use the [raw Files prerequisites](../raw_files/README.md) and the same exact native -source/toolchain. From the Parsar worktree: - -```sh -export RETIREMENT_ROOT="$HOME/.parsar/retirement-qualification" -python3 services/agents-api/tests/native/raw_manager/prepare.py \ - --manifest services/agents-api/tests/native/retirement/source.json \ - --source "$NATIVE_SOURCE" --output "$RETIREMENT_ROOT/source" -mkdir -p "$RETIREMENT_ROOT/state" -export TMPDIR="$RETIREMENT_ROOT/state" -export CARGO_HOME="$HOME/.parsar/cache/agents-native-cargo" -export CARGO_TARGET_DIR="$HOME/.parsar/cache/retirement-target" -export RUSTUP_TOOLCHAIN=1.95 -export CARGO_PROFILE_DEV_DEBUG=0 -cd "$RETIREMENT_ROOT/source/codex-rs" -cargo test --locked -p codex-exec-server --lib retirement_qualification -- --nocapture -cargo clippy --locked -p codex-exec-server --tests -- -D warnings -rustfmt --check --edition 2024 exec-server/src/retirement_gate.rs \ - exec-server/src/server/retirement_qualification.rs \ - exec-server/src/server/placement_qualification.rs -``` - -The test uses the existing processor's duplex JSON-RPC helper and typed native -`fs/writeFile` parameters with `followSymlinks: false`. A separately hashed, -`cfg(test)`-only gate pauses one existing blocking worker after it opens and -validates a regular file, immediately before the original truncation/write. It -changes scheduling, not the mutation algorithm. The gate matches one exact path, -releases on test unwind and has a 15-second timeout. The owned shell heartbeat is -bounded even if an assertion fails. This test does not exercise sandboxed writes, -all filesystem operations or every platform. - -In both cases the test observes the original worker entering, closes the connection -and joins its handler, verifies the mutation has no response, then admits a new -native owner writing the same regular file. The successor's acknowledged bytes -must be present before releasing the original worker. The original bytes then -replace them. One case additionally awaits the actual `ConnectionProcessor` -shutdown and observes the owned command PID disappear before admitting the -successor. Command retirement and blocking filesystem work are separate facts. -No unknown mutation is replayed. - -Successful test completion means this precise missing barrier was reproduced. -It does **not** mean retirement passed. If upstream behavior changes to settle -the held worker, re-evaluate the assertions and guarantee rather than weakening -the test to keep the negative result. - -## Retirement matrix - -| Boundary | Evidence and limit | -|---|---| -| Connection handler return | Instrumented native regression: the admitted blocking write remains live, and the owned command continues after detach. | -| `ConnectionProcessor::shutdown` return | Instrumented native regression: the owned command exits, while the held write can still overwrite a successor. This is not whole OS-process or Tokio-runtime destruction. | -| Native remote runner | Source: `remote.rs` calls the processor shutdown after the remote transport ends. This test exercises that processor boundary directly, not an end-to-end remote runner shutdown. | -| Registry pair disconnect / harness credential withdrawal | Source: the registry closes authenticated physical peers; native remote reconnect retains its processor. Transport closure is not a filesystem settlement receipt. Credential withdrawal is not directly injected by this test. | -| Core execution lease loss | Source: database ownership controls service admission; it cannot retract work already dispatched to a native executor. No lease-loss injection is claimed. | -| Executor process / isolated placement destruction | Qualified separately by the candidate fixture below for its task-owned local Docker placement. Production admission still needs an authorized supervisor/storage boundary or native mutation-drain receipt. | - -The replacement owner in this regression is admitted directly to native processors; -it is deliberately not evidence that public Core admission allows this race. It -shows why that admission must not infer write retirement from these boundaries. -Retain unresolved ownership and block successor mutation when the actual retirement -barrier is unknown, including recovery after Core restart. Do not equate connection -observation generations with a filesystem fence or change the public protocol. - -## Separate real execution acceptance - -The manifest reuses the manager hook and raw Files fixtures without changing their -native pin or third-party dependencies. The extra gate and test module compile -only in the native library unit-test build. Build the ordinary raw Files example -and run its existing first/fresh and cancellation workflows with a real model API, -the existing registry, pinned launcher and executor image. Record artifact/source -hashes separately from the instrumented library test and retain failures. Those -workflows verify actual Files, native commands and preserved history; they do not -upgrade this mechanism result into production retirement acceptance. - -Run repository `make check` independently. No API or database query is changed. -Public Files, production owner integration, complete descendant retirement and -Claude's independent placement qualification remain separate tasks. - -## Whole-placement candidate qualification - -`placement.py` uses the same exact-pin test build and scheduling gate in a -credential-free, task-owned Linux Docker unit. Build the `codex-exec-server` -library test binary with `cargo test --locked -p codex-exec-server --lib --no-run`, -then pass that binary and the already qualified immutable executor image: - -```sh -python3 services/agents-api/tests/native/retirement/placement.py \ - --binary "$NATIVE_TEST_BINARY" --image "$EXECUTOR_IMAGE_ID" \ - --output "$HOME/.parsar/placement-retirement/attempt-1" -``` - -The host must be the Docker host, expose readable cgroup v2 membership/events, -and use the existing Debian executor image with `setsid`. The test process is -the placement init; the native processor dispatches a held file write and a -command that starts a detached-session descendant. Before stopping, the runner -checks native readiness, actual cgroup members and independent process-session -identity. A still-live placement fails the retirement observation and cannot -start the successor. Docker stop is followed by cgroup and process-identity -observations before the fresh native write; a successful stop request alone is -insufficient. Failed assertions retain evidence and reclaim only exact labeled -test instances. Workspace files survive container removal. - -This qualifies only the tested local filesystem and placement. It does not -implement Core authority, durable unknown-owner reconciliation, remote supervisor -receipts, Claude containment or public Files. Whole-placement retirement does not -undo completed effects; the held old mutation remains unknown and is never -replayed. No model credentials enter the instrumented unit. Run the ordinary -real-model Files/cancellation/history fixture separately, and report its outcome -independently. Production runtime/pins and the earlier negative tests are unchanged. - -## Local Runtime operator consumer - -Build the existing daemon and pass `--controller /absolute/path/to/parsar-daemon` -to `placement.py` to exercise the actual local retirement command. The same held -native write and detached descendant are stopped through that consumer. Separate -CLI processes race on the binding and recover the identical receipt after removal; -the fixture independently checks old processes, retained successor bytes and an -untouched neighboring container. Scoped enrollment includes a generated Environment -UUID; wrong, missing and explicitly empty scopes must fail without stopping the -placement. This is operator-confirmed association, not Core resource validation. -Earlier native negative tests remain unchanged. - -Operators explicitly create an owned container with -`--label parsar.runtime.placement=`, then run: - -```sh -parsar-daemon placement enroll --container "$FULL_CONTAINER_ID" \ - --owner "$PLACEMENT_OWNER" --workspace "$ABSOLUTE_HOST_WORKSPACE" \ - --environment "$ENVIRONMENT_ID" -parsar-daemon placement retire --container "$FULL_CONTAINER_ID" \ - --environment "$ENVIRONMENT_ID" -``` - -This initial profile requires local Linux/cgroup v2 and the fixed socket -`unix:///var/run/docker.sock`; ambient Docker context/host variables do not select -the target. Use a non-root container user, private PID/IPC/cgroup namespaces, -`--network none --cap-drop ALL --security-opt no-new-privileges --restart no`, -no devices, additional capabilities, shared volumes or privileged settings. -Exactly one writable bind retains workspace/history on ext-family, XFS, Btrfs or -tmpfs storage without nested mounts; additional binds may only be read-only regular -files. Every source must be on a whole-filesystem host mount with exactly one mount -for that device in the controller namespace. Host bind aliases, Btrfs subvolume -roots, repeated-device or stacked mounts and missing mount evidence are rejected; -this first profile does not resolve arbitrary backing-path aliases. Controller state and the canonical Docker socket must not be exposed by any mount, -including ancestor directories and filesystem roots. Use canonical absolute -workspace paths and a trusted operator account with Docker access. State ancestors -must be owned by that user or root and not writable by group/others; the placement -state directory and files require modes 0700/0600. Host administrators remain trusted. - -The command persists intent before stop and verifies stopped state, cgroup emptiness -and old process identities before non-forced container removal. It never removes -workspace files or Docker volumes, releases an ordinary Turn, or replays unknown -writes. A saved completed receipt survives controller restart; recovery completes the -directory-sync barrier before returning success. Unavailable evidence, -changed incarnations and removal without a durable receipt remain unknown. An -interrupted stop can reconcile the same still-existing stopped unit. There is no -clear-unknown shortcut. This consumer does not gate Core dispatch or grant public -feature admission; remote authority, Claude placement and broader storage remain -separate work. Run full `make check` and uninstrumented real-provider acceptance -separately from the credential-free mechanism fixture. diff --git a/services/agents-api/tests/native/retirement/gate.rs b/services/agents-api/tests/native/retirement/gate.rs deleted file mode 100644 index 1e5772727..000000000 --- a/services/agents-api/tests/native/retirement/gate.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Qualification-only scheduling gate inside an existing native blocking write. -#![allow(clippy::expect_used)] - -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; - -static ARMED: Mutex>> = Mutex::new(None); - -pub(crate) struct Gate { - path: PathBuf, - pub(crate) entered: AtomicBool, - pub(crate) finished: AtomicBool, - released: Mutex, - changed: Condvar, -} - -pub(crate) struct ReleaseOnDrop(pub(crate) Arc); - -impl Gate { - pub(crate) fn arm(path: PathBuf) -> ReleaseOnDrop { - let gate = Arc::new(Self { - path, - entered: AtomicBool::new(false), - finished: AtomicBool::new(false), - released: Mutex::new(false), - changed: Condvar::new(), - }); - let mut armed = ARMED.lock().expect("gate registry"); - assert!(armed.is_none(), "only one qualification write at a time"); - *armed = Some(Arc::clone(&gate)); - ReleaseOnDrop(gate) - } - - pub(crate) fn release(&self) { - *self.released.lock().expect("gate release") = true; - self.changed.notify_all(); - } -} - -impl Drop for ReleaseOnDrop { - fn drop(&mut self) { - self.0.release(); - let mut armed = ARMED.lock().expect("gate registry"); - if armed - .as_ref() - .is_some_and(|gate| Arc::ptr_eq(gate, &self.0)) - { - *armed = None; - } - } -} - -pub(crate) struct Completion(Arc); - -impl Drop for Completion { - fn drop(&mut self) { - self.0.finished.store(true, Ordering::SeqCst); - } -} - -pub(crate) fn before_write(path: &Path) -> std::io::Result> { - let gate = { - let mut armed = ARMED.lock().expect("gate registry"); - if armed.as_ref().is_none_or(|gate| gate.path != path) { - return Ok(None); - } - armed.take().expect("matched gate") - }; - gate.entered.store(true, Ordering::SeqCst); - let (released, timeout) = gate - .changed - .wait_timeout_while( - gate.released.lock().expect("gate release"), - Duration::from_secs(15), - |released| !*released, - ) - .expect("gate wait"); - if timeout.timed_out() && !*released { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "qualification gate was not released", - )); - } - drop(released); - Ok(Some(Completion(gate))) -} diff --git a/services/agents-api/tests/native/retirement/operator_retirement.py b/services/agents-api/tests/native/retirement/operator_retirement.py deleted file mode 100644 index 5ecd084c3..000000000 --- a/services/agents-api/tests/native/retirement/operator_retirement.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Explicit local controller acceptance used by the native placement fixture.""" - -import hashlib -import json -from pathlib import Path -import subprocess -import uuid - - -class OperatorRetirement: - def __init__(self, binary, token, workspace): - self.binary = str(binary.resolve(strict=True)) - self.token = token - self.workspace = workspace - self.receipt = None - self.environment = str(uuid.uuid4()) - - def command(self, action, instance, *extra): - return [self.binary, "placement", action, "--container", instance, - "--environment", self.environment, *extra] - - def enroll(self, instance): - rejected = subprocess.run(self.command("enroll", instance, "--owner", "wrong-owner", - "--workspace", str(self.workspace)), capture_output=True, timeout=10) - assert rejected.returncode != 0, "unbound owner was accepted" - out = subprocess.check_output(self.command("enroll", instance, "--owner", self.token, - "--workspace", str(self.workspace)), text=True, timeout=10) - enrolled = json.loads(out) - assert enrolled["state"] == "enrolled" - assert enrolled["version"] == 2 and enrolled["environment_id"] == self.environment - for scope in ([], ["--environment", str(uuid.uuid4())], ["--environment", ""]): - rejected = subprocess.run([self.binary, "placement", "retire", "--container", instance, *scope], - capture_output=True, timeout=10) - assert rejected.returncode != 0, "mismatched or omitted Environment was accepted" - state = subprocess.check_output(["docker", "--host", "unix:///var/run/docker.sock", - "inspect", "--format", "{{.State.Running}}", instance], text=True) - assert state.strip() == "true", "scope rejection stopped the placement" - - def retire(self, instance): - # Separate controller processes race on the same exact durable binding. - children = [subprocess.Popen(self.command("retire", instance), stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True) for _ in range(2)] - results = [] - try: - for child in children: - out, error = child.communicate(timeout=10) - assert child.returncode == 0, error - results.append(json.loads(out)) - finally: - for child in children: - if child.poll() is None: - child.kill() - child.wait() - assert results[0] == results[1] - self.receipt = results[0] - assert self.receipt["state"] == "retired" - assert self.receipt["environment_id"] == self.environment - assert self.receipt["target"]["container"] == instance - assert self.receipt["owner"] == self.token - again = subprocess.check_output(self.command("retire", instance), text=True, timeout=10) - assert json.loads(again) == self.receipt - return {"controller_sha256": hashlib.sha256(Path(self.binary).read_bytes()).hexdigest(), - "local_receipt": self.receipt, "concurrent_and_fresh_process_receipts_equal": True, - "wrong_owner_rejected": True, "wrong_or_missing_environment_rejected": True} - - def observe(self, instance, cgroup, members, process_identity): - assert self.receipt and self.receipt["target"]["container"] == instance - absent = subprocess.run(["docker", "--host", "unix:///var/run/docker.sock", "inspect", instance], - capture_output=True, timeout=10) - assert absent.returncode != 0, "retired exact container must be removed" - events = cgroup / "cgroup.events" - if events.exists() and "populated 0" not in events.read_text().splitlines(): - return False - for pid, original in members.items(): - current = process_identity(pid) - if current and current["start"] == original["start"] and current["state"] not in {"Z", "X"}: - return False - return True diff --git a/services/agents-api/tests/native/retirement/placement.py b/services/agents-api/tests/native/retirement/placement.py deleted file mode 100644 index 9459fe18a..000000000 --- a/services/agents-api/tests/native/retirement/placement.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""Qualify exact-placement retirement; never a production ownership receipt.""" - -import argparse -import hashlib -import json -import os -from pathlib import Path -import subprocess -import time -import uuid - -from operator_retirement import OperatorRetirement - -TEST = "server::processor::tests::retirement_qualification::native_placement_worker" -LABEL = "parsar.retirement-qualification" - - -def docker(*args, timeout=15): - return subprocess.check_output(["docker", "--host", "unix:///var/run/docker.sock", *args], text=True, timeout=timeout).strip() - - -def inspect(instance): - return json.loads(docker("inspect", instance))[0] - - -def process_identity(pid): - try: - fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() - return {"start": fields[19], "state": fields[0], "group": fields[2], "session": fields[3]} - except FileNotFoundError: - return None - - -def observe_retired(instance, token, cgroup, members): - state = inspect(instance) - assert state["Config"]["Labels"][LABEL] == token - if state["State"]["Running"] or state["State"]["Pid"] != 0: - return False - events = cgroup / "cgroup.events" - if events.exists() and "populated 0" not in events.read_text().splitlines(): - return False - for pid, original in members.items(): - current = process_identity(pid) - if current and current["start"] == original["start"] and current["state"] != "Z": - return False - return True - - -def await_condition(condition, seconds, label): - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - if condition(): - return - time.sleep(0.025) - raise RuntimeError("timed out: " + label) - - -def qualify(args): - runtime = (Path.home() / ".parsar").resolve() - root = args.output.resolve() - if not root.is_relative_to(runtime) or root == runtime: - raise ValueError("output must be a new directory below ~/.parsar") - binary = args.binary.resolve(strict=True) - if os.getuid() == 0: - raise ValueError("run qualification as the ordinary executor user") - if not args.image.startswith("sha256:") or len(args.image) != 71: - raise ValueError("use the qualified immutable image ID") - root.mkdir(parents=True, mode=0o700, exist_ok=False) - workspace = root / "workspace" - workspace.mkdir(mode=0o700) - (workspace / "write.bin").write_bytes(b"initial") - filesystem = subprocess.check_output(["stat", "-f", "-c", "%T", str(workspace)], text=True).strip() - if filesystem not in {"ext2/ext3", "xfs", "btrfs", "tmpfs"}: - raise ValueError("this qualification requires a supported local filesystem") - token = uuid.uuid4().hex - instances = [] - removed = set() - controller = OperatorRetirement(args.controller, token, workspace) if args.controller else None - evidence = {"qualified": False, "instrumented": True, "model_calls": 0, - "image": args.image, "filesystem": filesystem, "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), - "scope": "task-owned local-filesystem Docker placement; no Core/remote authority claim"} - - def start(mode): - name = "parsar-retirement-" + token[:12] + "-" + mode - container_path = "/qualification/.parsar/task" - instance = docker("create", "--name", name, "--label", LABEL + "=" + token, - "--label", "parsar.runtime.placement=" + token, - "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", - "--restart", "no", "--user", f"{os.getuid()}:{os.getgid()}", - "--env", "HOME=/qualification", "--env", "PARSAR_RETIREMENT_MODE=" + mode, - "--env", "PARSAR_RETIREMENT_WORKSPACE=" + container_path, - "--mount", f"type=bind,src={binary},dst=/native-test,readonly", - "--mount", f"type=bind,src={workspace},dst={container_path}", - "--workdir", container_path, "--entrypoint", "/native-test", args.image, - "--exact", TEST, "--ignored", "--nocapture") - instances.append(instance) - docker("start", instance) - return instance - - try: - neighbor = None - if controller: - neighbor = docker("run", "-d", "--label", LABEL + "=" + token, - "--network", "none", "--entrypoint", "sleep", args.image, "60") - instances.append(neighbor) - neighbor_init = inspect(neighbor)["State"]["Pid"] - old = start("held") - await_condition(lambda: (workspace / "worker-entered").exists(), 7, "native worker gate") - state = inspect(old) - init = state["State"]["Pid"] - assert init > 0 and state["State"]["Running"] - host_config = state["HostConfig"] - assert not host_config["Privileged"] and host_config["PidMode"] != "host" - assert host_config["RestartPolicy"]["Name"] == "no" - assert "ALL" in host_config["CapDrop"] - assert "no-new-privileges" in host_config["SecurityOpt"] - groups = Path(f"/proc/{init}/cgroup").read_text().splitlines() - group = next(line[3:] for line in groups if line.startswith("0::")) - cgroup = Path("/sys/fs/cgroup") / group.lstrip("/") - assert cgroup != Path("/sys/fs/cgroup") and (cgroup / "cgroup.events").exists() - assert "populated 1" in (cgroup / "cgroup.events").read_text().splitlines() - members = {} - for procs in cgroup.rglob("cgroup.procs"): - for pid in procs.read_text().splitlines(): - identity = process_identity(pid) - if identity: - members[int(pid)] = identity - assert init in members and len(members) >= 3 - descendant = int((workspace / "descendant.pid").read_text()) - native_command = int((workspace / "command.pid").read_text()) - # The namespace PID has to be the leader of its own session, separately - # from the native command; this is not merely another process-group member. - stat = docker("exec", old, "cat", f"/proc/{descendant}/stat") - fields = stat.rsplit(")", 1)[1].split() - assert int(fields[2]) == descendant and int(fields[3]) == descendant - assert descendant != native_command - assert (workspace / "write.bin").read_bytes() == b"initial" - assert not observe_retired(old, token, cgroup, members) - try: - observe_retired("parsar-missing-" + token, token, cgroup, members) - except subprocess.CalledProcessError: - evidence["missing_retirement_observation_rejected"] = True - else: - raise AssertionError("missing supervisor evidence cannot authorize a successor") - assert len(instances) == (2 if controller else 1) and not (workspace / "successor-receipt").exists() - evidence.update(old_instance=old, cgroup=str(cgroup), observed_members=members, - detached_namespace_pid=descendant, live_owner_rejected=True) - began = time.monotonic() - if controller: - controller.enroll(old) - evidence.update(controller.retire(old)) - removed.add(old) - settled = lambda: controller.observe(old, cgroup, members, process_identity) - assert inspect(neighbor)["State"]["Pid"] == neighbor_init - evidence["neighbor_untouched"] = True - else: - docker("stop", "--timeout", "1", old) - settled = lambda: observe_retired(old, token, cgroup, members) - evidence["stopped_state"] = inspect(old)["State"] - await_condition(settled, 3, "placement retirement") - evidence["stop_seconds"] = time.monotonic() - began - counts = [(workspace / name).stat().st_size for name in ("heartbeat", "descendant-heartbeat")] - assert (workspace / "write.bin").read_bytes() == b"initial" - successor = start("successor") - exit_code = docker("wait", successor) - assert exit_code == "0", "successor native test failed" - assert (workspace / "successor-receipt").read_bytes() == b"acknowledged" - assert (workspace / "write.bin").read_bytes() == b"new-owner" - time.sleep(0.2) - assert [(workspace / name).stat().st_size for name in ("heartbeat", "descendant-heartbeat")] == counts - assert settled() - assert (workspace / "write.bin").read_bytes() == b"new-owner" - evidence.update(qualified=True, successor_instance=successor, old_write_receipt="unknown", - old_mutation_replayed=False, detached_effects_stopped=True, - successor_native_receipt=True, retained_bytes="new-owner") - finally: - cleanup, cleanup_errors = [], [] - for instance in reversed(instances): - if instance in removed: - cleanup.append(instance) - continue - try: - state = inspect(instance) - assert state["Config"]["Labels"][LABEL] == token - logs = subprocess.run(["docker", "logs", instance], text=True, capture_output=True, timeout=10) - (root / (instance + ".log")).write_text(logs.stdout + logs.stderr) - docker("rm", "--force", instance) - cleanup.append(instance) - except Exception as error: - cleanup_errors.append({"instance": instance, "error": str(error)}) - evidence["removed_task_instances"] = cleanup - evidence["cleanup_errors"] = cleanup_errors - (root / "result.json").write_text(json.dumps(evidence, indent=2) + "\n") - if cleanup_errors: - raise RuntimeError("task cleanup incomplete; inspect retained result.json") - print(json.dumps(evidence)) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--binary", type=Path, required=True, help="exact-manifest codex-exec-server test binary") - parser.add_argument("--image", required=True, help="existing qualified immutable executor image ID") - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--controller", type=Path, help="candidate parsar-daemon for local Runtime retirement acceptance") - qualify(parser.parse_args()) diff --git a/services/agents-api/tests/native/retirement/placement_test.rs b/services/agents-api/tests/native/retirement/placement_test.rs deleted file mode 100644 index 05bdcf3c1..000000000 --- a/services/agents-api/tests/native/retirement/placement_test.rs +++ /dev/null @@ -1,59 +0,0 @@ -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "requires the task-owned Docker placement runner"] -async fn native_placement_worker() { - let root = std::path::PathBuf::from( - std::env::var_os("PARSAR_RETIREMENT_WORKSPACE").expect("qualification workspace"), - ); - let home = std::path::PathBuf::from(std::env::var_os("HOME").expect("private HOME")); - assert!(root.is_absolute() && root.starts_with(home.join(".parsar"))); - let mode = std::env::var("PARSAR_RETIREMENT_MODE").expect("qualification mode"); - assert!(mode == "held" || mode == "successor"); - let file = root.join("write.bin"); - let processor = super::super::ConnectionProcessor::new(test_runtime_paths()); - let (mut writer, mut lines, handler) = - spawn_test_connection(Arc::clone(&processor.session_registry), &mode); - initialize(&mut writer, &mut lines, None).await; - if mode == "successor" { - send_request( - &mut writer, - 2, - FS_WRITE_FILE_METHOD, - &write_params(&file, b"new-owner"), - ) - .await; - let _: FsWriteFileResponse = read_response(&mut lines, 2).await; - assert_eq!(std::fs::read(&file).expect("successor bytes"), b"new-owner"); - std::fs::write(root.join("successor-receipt"), b"acknowledged").expect("receipt marker"); - drop(writer); - handler.await.expect("successor handler"); - processor.shutdown().await; - return; - } - assert_eq!(std::fs::read(&file).expect("initial bytes"), b"initial"); - let mut command = exec_params(ProcessId::from("placement-command")); - command.cwd = PathUri::from_host_native_path(&root).expect("workspace URI"); - command.argv = vec!["/bin/sh".into(), "-c".into(), - "setsid /bin/sh -c 'echo $$ > descendant.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> descendant-heartbeat; sleep 0.02; i=$((i+1)); done' /dev/null 2>&1 & echo $$ > command.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> heartbeat; sleep 0.02; i=$((i+1)); done".into()]; - send_request(&mut writer, 2, EXEC_METHOD, &command).await; - let _: ExecResponse = read_response(&mut lines, 2).await; - wait_for(|| { - root.join("descendant.pid").exists() - && heartbeat(&root) > 0 - && std::fs::metadata(root.join("descendant-heartbeat")).is_ok_and(|m| m.len() > 0) - }) - .await; - let gate = Gate::arm(file.clone()); - send_request( - &mut writer, - 3, - FS_WRITE_FILE_METHOD, - &write_params(&file, b"old-owner"), - ) - .await; - wait_for(|| gate.0.entered.load(Ordering::SeqCst)).await; - std::fs::write(root.join("worker-entered"), b"held-before-truncate").expect("ready marker"); - // The external supervisor must retire this placement while the original - // blocking worker still owns its descriptor; returning would release it. - tokio::time::sleep(Duration::from_secs(12)).await; - panic!("placement was not retired before the qualification deadline"); -} diff --git a/services/agents-api/tests/native/retirement/processor_test.rs b/services/agents-api/tests/native/retirement/processor_test.rs deleted file mode 100644 index 06b1c601d..000000000 --- a/services/agents-api/tests/native/retirement/processor_test.rs +++ /dev/null @@ -1,208 +0,0 @@ -mod retirement_qualification { - use super::*; - use crate::protocol::{FS_WRITE_FILE_METHOD, FsWriteFileParams, FsWriteFileResponse}; - use crate::retirement_gate::Gate; - use base64::Engine; - use base64::prelude::BASE64_STANDARD; - use pretty_assertions::assert_eq; - use std::path::Path; - use std::sync::atomic::Ordering; - - // This is a negative qualification: native detach/shutdown is not a Files - // retirement receipt. The original blocking write is never retried. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn native_retirement_does_not_settle_admitted_write() { - for shutdown in [false, true] { - qualify(shutdown).await; - } - } - - async fn qualify(shutdown: bool) { - let private = std::path::PathBuf::from(std::env::var_os("HOME").expect("caller HOME")) - .join(".parsar") - .canonicalize() - .expect("private state root"); - let temporary = - std::path::PathBuf::from(std::env::var_os("TMPDIR").expect("private TMPDIR")) - .canonicalize() - .expect("temporary state root"); - assert!( - temporary.starts_with(private), - "TMPDIR must be below ~/.parsar" - ); - let root = tempfile::Builder::new() - .prefix("retirement-") - .tempdir_in(temporary) - .expect("private workspace"); - let started = std::time::Instant::now(); - let mut timeline = Vec::new(); - let mut observed = |event: &str| { - timeline.push(serde_json::json!({ - "event": event, "elapsed_us": started.elapsed().as_micros(), - })) - }; - let file = root.path().join("write.bin"); - std::fs::write(&file, b"initial").expect("initial file"); - let processor = super::super::ConnectionProcessor::new(test_runtime_paths()); - let registry = Arc::clone(&processor.session_registry); - let (mut writer, mut lines, first) = spawn_test_connection(Arc::clone(®istry), "old"); - initialize(&mut writer, &mut lines, None).await; - - let mut command = exec_params(ProcessId::from("retirement-command")); - command.cwd = PathUri::from_host_native_path(root.path()).expect("workspace URI"); - command.argv = vec![ - "/bin/sh".into(), "-c".into(), - "echo $$ > command.pid; i=0; while [ \"$i\" -lt 1000 ]; do printf x >> heartbeat; sleep 0.02; i=$((i+1)); done".into(), - ]; - send_request(&mut writer, 2, EXEC_METHOD, &command).await; - let _: ExecResponse = read_response(&mut lines, 2).await; - wait_for(|| root.path().join("command.pid").exists() && heartbeat(root.path()) > 0).await; - let pid: u32 = std::fs::read_to_string(root.path().join("command.pid")) - .expect("command pid") - .trim() - .parse() - .expect("numeric pid"); - let process = format!("/proc/{pid}"); - assert!(Path::new(&process).exists(), "owned command must exist"); - - let gate = Gate::arm(file.clone()); - send_request( - &mut writer, - 3, - FS_WRITE_FILE_METHOD, - &write_params(&file, b"old-owner"), - ) - .await; - wait_for(|| gate.0.entered.load(Ordering::SeqCst)).await; - observed("original_blocking_worker_entered"); - assert_eq!(std::fs::read(&file).expect("held bytes"), b"initial"); - drop(writer); - timeout(Duration::from_secs(2), first) - .await - .expect("disconnected handler should return") - .expect("handler join"); - observed("connection_handler_returned"); - assert!( - lines - .next_line() - .await - .expect("old response stream") - .is_none(), - "held mutation must have no response" - ); - assert!(!gate.0.finished.load(Ordering::SeqCst)); - let before = heartbeat(root.path()); - wait_for(|| heartbeat(root.path()) > before).await; - - if shutdown { - timeout(Duration::from_secs(2), processor.shutdown()) - .await - .expect("native processor shutdown should return"); - observed("native_processor_shutdown_returned"); - wait_for(|| !Path::new(&process).exists()).await; - observed("owned_command_exit_observed"); - assert!( - !gate.0.finished.load(Ordering::SeqCst), - "local command retirement is distinct from the admitted file worker" - ); - } - - // A new native owner for the same path can be admitted while the old - // blocking worker still holds its descriptor. No Core policy is implied. - let successor = super::super::ConnectionProcessor::new(test_runtime_paths()); - let (mut next_writer, mut next_lines, next) = - spawn_test_connection(Arc::clone(&successor.session_registry), "successor"); - initialize(&mut next_writer, &mut next_lines, None).await; - send_request( - &mut next_writer, - 2, - FS_WRITE_FILE_METHOD, - &write_params(&file, b"new-owner"), - ) - .await; - let _: FsWriteFileResponse = - timeout(Duration::from_secs(2), read_response(&mut next_lines, 2)) - .await - .expect("successor write receipt"); - assert_eq!(std::fs::read(&file).expect("successor bytes"), b"new-owner"); - assert!(!gate.0.finished.load(Ordering::SeqCst)); - observed("successor_write_acknowledged_and_bytes_verified"); - gate.0.release(); - wait_for(|| gate.0.finished.load(Ordering::SeqCst)).await; - assert_eq!(std::fs::read(&file).expect("late bytes"), b"old-owner"); - observed("original_worker_finished_and_overwrote_successor"); - - drop(next_writer); - drop(next_lines); - timeout(Duration::from_secs(2), next) - .await - .expect("successor shutdown") - .expect("successor join"); - successor.shutdown().await; - processor.shutdown().await; - wait_for(|| !Path::new(&process).exists()).await; - let stopped = heartbeat(root.path()); - tokio::time::sleep(Duration::from_millis(100)).await; - assert_eq!(heartbeat(root.path()), stopped, "owned heartbeat stopped"); - println!( - "{}", - serde_json::json!({ - "qualification": "native-retirement", "instrumented": true, - "boundary": if shutdown { "processor-shutdown" } else { "connection-detach" }, - "write_dispatched_and_worker_entered": true, "old_receipt": "unknown", - "handler_returned_while_write_held": true, - "command_continued_after_detach": true, - "processor_shutdown_before_successor": shutdown, - "successor_receipt_before_old_completion": true, - "bytes": ["initial", "new-owner", "old-owner"], - "owned_command_exit_observed": true, "retirement_barrier": false, - "timeline": timeline, - }) - ); - } - - async fn initialize( - writer: &mut DuplexStream, - lines: &mut Lines>, - resume: Option, - ) { - send_request( - writer, - 1, - INITIALIZE_METHOD, - &InitializeParams { - client_name: "retirement-qualification".into(), - resume_session_id: resume, - }, - ) - .await; - let _: InitializeResponse = read_response(lines, 1).await; - send_notification(writer, INITIALIZED_METHOD, &()).await; - } - - fn write_params(path: &Path, data: &[u8]) -> FsWriteFileParams { - FsWriteFileParams { - path: PathUri::from_host_native_path(path).expect("file URI"), - data_base64: BASE64_STANDARD.encode(data), - follow_symlinks: Some(false), - sandbox: None, - } - } - - fn heartbeat(root: &Path) -> u64 { - std::fs::metadata(root.join("heartbeat")) - .map(|m| m.len()) - .unwrap_or(0) - } - - async fn wait_for(mut predicate: impl FnMut() -> bool) { - timeout(Duration::from_secs(2), async { - while !predicate() { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("bounded observation"); - } - include!("placement_qualification.rs"); -} diff --git a/services/agents-api/tests/native/retirement/qualification.patch b/services/agents-api/tests/native/retirement/qualification.patch deleted file mode 100644 index 0676082ad..000000000 --- a/services/agents-api/tests/native/retirement/qualification.patch +++ /dev/null @@ -1,55 +0,0 @@ ---- a/codex-rs/app-server/Cargo.toml -+++ b/codex-rs/app-server/Cargo.toml -@@ -118,6 +118,7 @@ - codex-windows-sandbox = { workspace = true } - - [dev-dependencies] -+codex-app-server-client = { workspace = true } - app_test_support = { workspace = true } - axum = { workspace = true, default-features = false, features = [ - "http1", ---- a/codex-rs/Cargo.lock -+++ b/codex-rs/Cargo.lock -@@ -2032,6 +2032,7 @@ - "clap", - "codex-agent-extension", - "codex-analytics", -+ "codex-app-server-client", - "codex-app-server-protocol", - "codex-app-server-transport", - "codex-arg0", ---- a/codex-rs/exec-server/src/lib.rs -+++ b/codex-rs/exec-server/src/lib.rs -@@ -208,6 +208,9 @@ - pub use server::DEFAULT_LISTEN_URL; - pub use server::ExecServerListenUrlParseError; - pub use server::RequestDispatchMode; -+#[cfg(all(test, target_os = "linux"))] -+mod retirement_gate; -+ - pub use server::run_main; - pub use server::run_main_with_telemetry; - pub use telemetry::ExecServerTelemetry; ---- a/codex-rs/exec-server/src/no_follow/unix.rs -+++ b/codex-rs/exec-server/src/no_follow/unix.rs -@@ -138,6 +138,8 @@ - "path is not a regular file", - )); - } -+ #[cfg(all(test, target_os = "linux"))] -+ let _retirement_write = crate::retirement_gate::before_write(&path)?; - file.set_len(0)?; - file.write_all(&contents) - }) ---- a/codex-rs/exec-server/src/server/processor.rs -+++ b/codex-rs/exec-server/src/server/processor.rs -@@ -481,6 +481,9 @@ - .expect("second processor should join"); - } - -+ #[cfg(target_os = "linux")] -+ include!("retirement_qualification.rs"); -+ - fn spawn_test_connection( - registry: Arc, - label: &str, diff --git a/services/agents-api/tests/native/retirement/source.json b/services/agents-api/tests/native/retirement/source.json deleted file mode 100644 index c21f49617..000000000 --- a/services/agents-api/tests/native/retirement/source.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "repository": "https://github.com/openai/codex", - "revision": "3d2ee51ca2d5db578f328aa75e20aa22c0197c9a", - "native_version": "0.153.4", - "rust_toolchain": "1.95.0", - "scope": "private Linux executor-retirement qualification; test-only timing overlay, no production adoption", - "patch": { - "file": "../../../../../packages/codex-harness/patches/manager-exposure.patch", - "sha256": "047e1fe5e267cfc350e0a6373e50eeff5c51581bdaffaf29807430d35b8c8adc" - }, - "cargo_lock": { - "original_sha256": "3494b8a78d0f643556a83a9cc184e912bcab9f4c5640288952f4223452ba5dc8", - "normalized_sha256": "a2cb91dfb2e8112bc81d05158fa00b9698e2df8cc1ae0547b5dc5606a44904d3", - "workspace_packages": 149 - }, - "fixtures": [ - { - "source": "../raw_files/../shared_files_probe.rs", - "target": "codex-rs/app-server/examples/parsar_shared_files_probe.rs" - }, - { - "source": "../raw_files/../raw_files_probe.rs", - "target": "codex-rs/app-server/examples/parsar_raw_files_probe.rs" - }, - { - "source": "../raw_files/../shared_files/files.rs", - "target": "codex-rs/app-server/examples/shared_files/files.rs" - }, - { - "source": "../raw_files/../shared_files/runtime.rs", - "target": "codex-rs/app-server/examples/shared_files/runtime.rs" - }, - { - "source": "../raw_files/../shared_files/raw_runtime.rs", - "target": "codex-rs/app-server/examples/shared_files/raw_runtime.rs" - }, - { - "source": "../raw_files/../shared_files/probe.rs", - "target": "codex-rs/app-server/examples/shared_files/probe.rs" - }, - { - "source": "../raw_files/../shared_files/observations.rs", - "target": "codex-rs/app-server/examples/shared_files/observations.rs" - }, - { - "source": "../raw_files/../shared_files/configuration.rs", - "target": "codex-rs/app-server/examples/shared_files/configuration.rs" - }, - { - "source": "../raw_files/../shared_files/cancellation.rs", - "target": "codex-rs/app-server/examples/shared_files/cancellation.rs" - }, - { - "source": "gate.rs", - "target": "codex-rs/exec-server/src/retirement_gate.rs" - }, - { - "source": "processor_test.rs", - "target": "codex-rs/exec-server/src/server/retirement_qualification.rs" - }, - { - "source": "placement_test.rs", - "target": "codex-rs/exec-server/src/server/placement_qualification.rs" - } - ], - "fixture_patch": { - "file": "qualification.patch", - "sha256": "1d6d990cd026f9299ac4e4eef401280e1b5ef6a98806c90115e14f86af00d32b", - "scope": "existing raw-client test dependency plus cfg(test) Linux scheduling gate in an existing blocking write; unchanged production mutation implementation", - "prepared_files": { - "codex-rs/app-server/Cargo.toml": "c0d1cbed4ab6256ba28a3bbfaad7b8217ae928dce29b8299bd725027c63e350e", - "codex-rs/Cargo.lock": "25dbeba0fe924e6501168dd8b54670a6ec9d0639cd3ad0ccbb5828f1d4b60fa6", - "codex-rs/exec-server/src/lib.rs": "19af0c8d0da8273da1b9c82eeda9df6f77b61d65fd52a8c42ff764067cfe682b", - "codex-rs/exec-server/src/no_follow/unix.rs": "97bad67a8c821dff0b888678507a08e5faa133bd1b01bf9d9ae02142d9a889e6", - "codex-rs/exec-server/src/server/processor.rs": "eb0599df128dd0ccfe9ba6ada65e90b518a985b17ce518df542b11ead61f9f83" - } - } -} diff --git a/services/agents-api/tests/native/shared_files/cancellation.rs b/services/agents-api/tests/native/shared_files/cancellation.rs deleted file mode 100644 index fc095d4da..000000000 --- a/services/agents-api/tests/native/shared_files/cancellation.rs +++ /dev/null @@ -1,394 +0,0 @@ -use std::path::Path; -use std::time::Duration; - -use anyhow::{Context, Result, ensure}; -use serde_json::{Value, json}; -use tokio::time::{sleep, timeout}; -use uuid::Uuid; - -use crate::files::RemoteFiles; -use crate::observations::is_gate_command; -use crate::probe::{field, wait_checkpoint}; -use crate::runtime::Runtime; - -pub async fn exercise( - root: &Path, - workspace: &Path, - fs: &RemoteFiles, - client: &Runtime, -) -> Result { - let previous = read_proof(&root.join("first.json")).await?; - let thread = field(&previous, "native_thread_id")?; - let mut files = fs.idle("cancel", &previous).await?; - let resumed = client - .request( - 1, - "thread/resume", - json!({"threadId":thread,"cwd":workspace}), - ) - .await?; - ensure!( - resumed["thread"]["id"] == thread, - "cancel resume changed native thread" - ); - let prompt = "Execute exactly one command: `./shared-gate.sh cancel`. Pass that entire command with its required cancel argument to the native exec_command tool. It waits on a bounded fixture gate and will be interrupted externally. Keep waiting with native polling if needed. Never rerun it or execute another command, and do not open its gate or read/write files yourself."; - let response = client.request(2, "turn/start", json!({"threadId":thread,"input":[{"type":"text","text":prompt}],"environments":[{"environmentId":"remote","cwd":workspace}]})).await?; - let turn = field(&response["turn"], "id")?; - let heartbeat = timeout(Duration::from_secs(150), async { - loop { - client.observations.require_unfinished()?; - if let Some(heartbeat) = fs.optional_text("cancel.heartbeat").await? { - ensure!(!heartbeat.trim().is_empty(), "empty cancellation heartbeat"); - client.observations.wait_active(thread, turn).await?; - return Ok::<_, anyhow::Error>(heartbeat); - } - sleep(Duration::from_millis(100)).await; - } - }) - .await - .context("cancel command heartbeat timed out")??; - wait_checkpoint(&root.join("cancel-active-observed"), client).await?; - client.observations.require_active(thread, turn)?; - let started = command_started(&client.observations.events()?, thread, turn, workspace)?; - fs.verify_binary(&files).await?; - let active_directory = fs.names().await?; - require_closed_gate(fs).await?; - let marker = format!("shared-marker-{}", Uuid::now_v7()); - fs.write("cancel-marker.txt", format!("{marker}\n").into_bytes()) - .await?; - ensure!( - fs.text("cancel-marker.txt").await? == format!("{marker}\n"), - "active cancel marker differs" - ); - client.observations.require_active(thread, turn)?; - - // The native acknowledgement can also race natural completion. The separate - // terminal notification must identify this Turn with status interrupted. - let interrupt = client - .request( - 3, - "turn/interrupt", - json!({"threadId":thread,"turnId":turn}), - ) - .await?; - ensure!( - interrupt == json!({}), - "unexpected native interrupt response" - ); - client.observations.wait_completed().await?; - let completed_turn = interrupted_turn(&client.observations.events()?, thread, turn)?; - let before = client - .request( - 4, - "thread/backgroundTerminals/list", - json!({"threadId":thread,"limit":10}), - ) - .await?; - let termination = if let Some(target) = select_target(&before, &started)? { - let process = field(target, "processId")?; - let response = client - .request( - 5, - "thread/backgroundTerminals/terminate", - json!({"threadId":thread,"processId":process}), - ) - .await?; - ensure!( - response["terminated"] == true, - "native targeted termination was not confirmed" - ); - json!({"request_id":5,"process_id":process,"response":response}) - } else { - Value::Null - }; - let after = client - .request( - 6, - "thread/backgroundTerminals/list", - json!({"threadId":thread,"limit":10}), - ) - .await?; - ensure!( - select_target(&after, &started)?.is_none(), - "cancelled native target remains listed" - ); - // This is an observed native target state, not proof of OS quiescence. Go - // independently checks the fixture PID while this owner is still alive. - fs.verify_binary(&files).await?; - ensure!( - fs.text("cancel-marker.txt").await? == format!("{marker}\n"), - "post-cancel marker differs" - ); - require_closed_gate(fs).await?; - ensure!( - fs.text("shared-gate-count") - .await? - .lines() - .collect::>() - == ["first", "cancel"], - "cancel command was repeated" - ); - ensure!( - fs.text("cancel.cwd").await?.trim() == workspace.to_str().context("workspace encoding")?, - "cancel command cwd differs" - ); - files["active_heartbeat"] = json!(heartbeat); - files["active_binary_verified"] = json!(true); - files["active_directory_names"] = json!(active_directory); - files["directory_names"] = json!(fs.names().await?); - let mut proof = json!({ - "phase":"cancel", "native_thread_id":thread, "native_turn_id":turn, - "marker":marker,"history_value":field(&previous,"history_value")?,"files":files, - "shutdown_completed":false,"no_lagged_observed":true, - "cancellation":{ - "interrupt":{"request_id":3,"response":interrupt}, - "turn_completed":completed_turn,"command_started":started,"command_completed":null, - "background_before":before,"termination":termination,"background_after":after, - "files_after":{"binary_verified":true,"marker_verified":true}, - "observation_scope":"Typed native notification bodies observed before the snapshot; absent completion is not reconstructed." - } - }); - refresh(&mut proof, client)?; - crate::runtime::annotate(&mut proof); - Ok(proof) -} - -async fn require_closed_gate(fs: &RemoteFiles) -> Result<()> { - ensure!( - fs.optional_text("cancel.release").await?.is_none(), - "cancel gate was released" - ); - ensure!( - fs.optional_text("cancel-artifact.txt").await?.is_none(), - "cancel command reached placement" - ); - Ok(()) -} - -pub fn refresh(proof: &mut Value, client: &Runtime) -> Result<()> { - let events = client.observations.events()?; - let thread = field(proof, "native_thread_id")?; - let turn = field(proof, "native_turn_id")?; - let terminal = interrupted_turn(&events, thread, turn)?; - let completed = notification(&events, "item/completed", true, false)?; - if !completed.is_null() { - ensure!( - completed["threadId"] == thread - && completed["turnId"] == turn - && completed["item"]["id"] - == proof["cancellation"]["command_started"]["item"]["id"], - "cancel completion belongs to another native command" - ); - let process = &completed["item"]["processId"]; - ensure!( - process.is_null() - || process == &proof["cancellation"]["command_started"]["item"]["processId"], - "cancel completion process changed" - ); - } - proof["command_completed_count"] = json!(usize::from(!completed.is_null())); - proof["cancellation"]["command_completed"] = completed; - proof["cancellation"]["turn_completed"] = terminal; - proof["turn_started_count"] = json!(1); - proof["turn_completed_count"] = json!(1); - proof["command_started_count"] = json!(1); - proof["events"] = json!(events); - Ok(()) -} - -pub async fn recover( - root: &Path, - thread: &str, - fs: &RemoteFiles, - client: &Runtime, -) -> Result> { - let path = root.join("cancel.json"); - if !tokio::fs::try_exists(&path) - .await - .context("inspect cancellation proof")? - { - return Ok(None); - } - let cancelled = read_proof(&path).await?; - ensure!( - cancelled["native_thread_id"] == thread && cancelled["shutdown_completed"] == true, - "cancel owner did not finish on the resumed thread" - ); - let turns = client - .request( - 3, - "thread/turns/list", - json!({"threadId":thread,"limit":10,"sortDirection":"desc"}), - ) - .await?; - validate_recovery(&turns, field(&cancelled, "native_turn_id")?)?; - fs.verify_binary(&cancelled["files"]).await?; - ensure!( - fs.text("cancel-marker.txt").await? == format!("{}\n", field(&cancelled, "marker")?), - "fresh cancel marker differs" - ); - Ok(Some(json!({"native_turns":turns,"files_verified":true}))) -} - -async fn read_proof(path: &Path) -> Result { - serde_json::from_slice( - &tokio::fs::read(path) - .await - .context("read prior native proof")?, - ) - .context("decode prior native proof") -} - -fn notification( - events: &[Value], - method: &str, - command_only: bool, - required: bool, -) -> Result { - let matching: Vec<_> = events - .iter() - .filter(|event| { - event["method"] == method - && (!command_only || event["params"]["item"]["type"] == "commandExecution") - }) - .collect(); - ensure!( - matching.len() <= 1 && (!required || matching.len() == 1), - "native cancellation lifecycle count differs" - ); - Ok(matching - .first() - .map(|event| event["params"].clone()) - .unwrap_or(Value::Null)) -} - -fn command_started(events: &[Value], thread: &str, turn: &str, workspace: &Path) -> Result { - let started = notification(events, "item/started", true, true)?; - ensure!( - started["threadId"] == thread && started["turnId"] == turn, - "cancel command belongs to another Turn" - ); - let item = &started["item"]; - field(item, "id")?; - field(item, "processId")?; - ensure!( - item["cwd"] == workspace.to_str().context("workspace encoding")? - && is_gate_command(field(item, "command")?, "cancel"), - "unexpected cancel command or cwd" - ); - Ok(started) -} - -fn interrupted_turn(events: &[Value], thread: &str, turn: &str) -> Result { - let started = notification(events, "turn/started", false, true)?; - ensure!( - started["threadId"] == thread && started["turn"]["id"] == turn, - "cancel Turn start identity differs" - ); - let terminal = notification(events, "turn/completed", false, true)?; - ensure!( - terminal["threadId"] == thread - && terminal["turn"]["id"] == turn - && terminal["turn"]["status"] == "interrupted", - "native cancel Turn did not end interrupted" - ); - Ok(terminal) -} - -fn select_target<'a>(listed: &'a Value, started: &Value) -> Result> { - ensure!( - listed["nextCursor"].is_null(), - "unexpected background-terminal pagination" - ); - let data = listed["data"] - .as_array() - .context("native background-terminal list missing")?; - ensure!(data.len() <= 1, "ambiguous native background terminals"); - let Some(target) = data.first() else { - return Ok(None); - }; - let item = &started["item"]; - ensure!( - target["itemId"] == item["id"] - && target["processId"] == item["processId"] - && target["cwd"] == item["cwd"] - && is_gate_command(field(target, "command")?, "cancel"), - "background terminal is not the observed current-Turn command" - ); - Ok(Some(target)) -} - -fn validate_recovery(turns: &Value, cancelled_turn: &str) -> Result<()> { - ensure!( - turns["nextCursor"].is_null(), - "unexpected native recovery pagination" - ); - let data = turns["data"] - .as_array() - .context("native recovery turns missing")?; - ensure!( - data.len() == 2, - "native recovery did not retain both prior Turns" - ); - let matching: Vec<_> = data - .iter() - .filter(|turn| turn["id"] == cancelled_turn) - .collect(); - ensure!( - matching.len() == 1 && matching[0]["status"] == "interrupted", - "native history did not retain the cancelled Turn" - ); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{command_started, interrupted_turn, select_target, validate_recovery}; - use serde_json::json; - use std::path::Path; - - #[test] - fn cancellation_target_requires_current_turn_and_process() -> anyhow::Result<()> { - let mut event = json!({"method":"item/started","params":{"threadId":"thread","turnId":"cancel","item":{"type":"commandExecution","id":"item","processId":"42","cwd":"/remote","command":"/bin/sh -lc './shared-gate.sh cancel'"}}}); - let started = command_started(&[event.clone()], "thread", "cancel", Path::new("/remote"))?; - let mut list = json!({"data":[{"itemId":"item","processId":"42","cwd":"/remote","command":"./shared-gate.sh cancel"}],"nextCursor":null}); - assert!(select_target(&list, &started)?.is_some()); - list["data"][0]["processId"] = json!("43"); - assert!(select_target(&list, &started).is_err()); - list["data"][0]["processId"] = json!("42"); - list["data"][0]["itemId"] = json!("other"); - assert!(select_target(&list, &started).is_err()); - event["params"]["turnId"] = json!("previous"); - assert!( - command_started(&[event.clone()], "thread", "cancel", Path::new("/remote")).is_err() - ); - event["params"]["turnId"] = json!("cancel"); - event["params"]["item"]["processId"] = json!(null); - assert!(command_started(&[event], "thread", "cancel", Path::new("/remote")).is_err()); - Ok(()) - } - - #[test] - fn acknowledgement_does_not_substitute_for_interrupted_turn() -> anyhow::Result<()> { - let start = - json!({"method":"turn/started","params":{"threadId":"thread","turn":{"id":"cancel"}}}); - let mut terminal = json!({"method":"turn/completed","params":{"threadId":"thread","turn":{"id":"cancel","status":"interrupted"}}}); - interrupted_turn(&[start.clone(), terminal.clone()], "thread", "cancel")?; - terminal["params"]["turn"]["status"] = json!("completed"); - assert!(interrupted_turn(&[start, terminal], "thread", "cancel").is_err()); - Ok(()) - } - - #[test] - fn recovery_requires_exact_cancelled_turn_once() -> anyhow::Result<()> { - let mut page = json!({"data":[{"id":"first","status":"completed"},{"id":"cancel","status":"interrupted"}],"nextCursor":null}); - validate_recovery(&page, "cancel")?; - assert!(validate_recovery(&page, "other").is_err()); - page["data"][1]["status"] = json!("completed"); - assert!(validate_recovery(&page, "cancel").is_err()); - page["data"] = - json!([{"id":"cancel","status":"interrupted"},{"id":"cancel","status":"interrupted"}]); - assert!(validate_recovery(&page, "cancel").is_err()); - Ok(()) - } -} diff --git a/services/agents-api/tests/native/shared_files/configuration.rs b/services/agents-api/tests/native/shared_files/configuration.rs deleted file mode 100644 index 7547e8356..000000000 --- a/services/agents-api/tests/native/shared_files/configuration.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, ensure}; - -pub fn overrides() -> Vec<(String, toml::Value)> { - let mut overrides: Vec<(String, toml::Value)> = vec![ - ("model", "MiniMax-M3"), - ("model_provider", "shared_files"), - ("approval_policy", "never"), - ("sandbox_mode", "danger-full-access"), - ("web_search", "disabled"), - ("shell_environment_policy.inherit", "core"), - ( - "model_providers.shared_files.name", - "Shared native files acceptance", - ), - ( - "model_providers.shared_files.base_url", - "https://api.minimax.cn/v1", - ), - ( - "model_providers.shared_files.env_key", - "PARSAR_PROBE_MODEL_KEY", - ), - ("model_providers.shared_files.wire_api", "responses"), - ] - .into_iter() - .map(|(key, value)| (key.to_owned(), toml::Value::String(value.to_owned()))) - .collect(); - overrides.push(( - "features.multi_agent".to_owned(), - toml::Value::Boolean(false), - )); - overrides.push(( - "shell_environment_policy.ignore_default_excludes".to_owned(), - toml::Value::Boolean(false), - )); - overrides.push(( - "shell_environment_policy.exclude".to_owned(), - toml::Value::Array(vec![ - toml::Value::String("PARSAR_PLACEMENT_MODEL_KEY_FILE".to_owned()), - toml::Value::String("PARSAR_PROBE_MODEL_KEY".to_owned()), - toml::Value::String("CODEX_EXEC_SERVER_NOISE_*".to_owned()), - ]), - )); - overrides -} - -pub fn caller_state_root() -> Result { - let home = PathBuf::from( - std::env::var_os("PARSAR_SHARED_FILES_CALLER_HOME") - .context("original caller HOME is required")?, - ); - ensure!(home.is_absolute(), "original caller HOME must be absolute"); - std::fs::canonicalize(home.join(".parsar")).context("canonical caller state root is missing") -} - -pub fn proof_root(root: &Path, state_root: &Path) -> Result { - ensure!(root.is_absolute(), "proof root must be absolute"); - let root = std::fs::canonicalize(root).context("canonical proof root is missing")?; - ensure!( - root.is_dir() && root.starts_with(state_root), - "proof root must be under caller ~/.parsar" - ); - Ok(root) -} - -#[cfg(test)] -mod tests { - use super::proof_root; - use std::path::PathBuf; - - #[test] - fn proof_root_uses_canonical_caller_boundary() -> anyhow::Result<()> { - // Even rejected test paths stay below the real caller's private state root. - let private = - PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME missing"))?) - .join(".parsar") - .canonicalize()?; - let fixture = tempfile::Builder::new() - .prefix("files-root-test-") - .tempdir_in(private)?; - let state = fixture.path().join("caller/.parsar"); - let accepted = state.join("proof"); - let outside = fixture.path().join("outside"); - let impostor = outside.join(".parsar/proof"); - std::fs::create_dir_all(&accepted)?; - std::fs::create_dir_all(&impostor)?; - let state = state.canonicalize()?; - assert_eq!(proof_root(&accepted, &state)?, accepted.canonicalize()?); - assert!(proof_root(&impostor, &state).is_err()); - assert!(proof_root(&state.join("../../outside"), &state).is_err()); - #[cfg(unix)] - { - std::os::unix::fs::symlink(&outside, state.join("escape"))?; - assert!(proof_root(&state.join("escape"), &state).is_err()); - } - Ok(()) - } -} diff --git a/services/agents-api/tests/native/shared_files/files.rs b/services/agents-api/tests/native/shared_files/files.rs deleted file mode 100644 index 072899d74..000000000 --- a/services/agents-api/tests/native/shared_files/files.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, Result, ensure}; -use codex_exec_server::{ - ExecutorFileSystem, GetMetadataOptions, ReadFileOptions, WriteFileOptions, -}; -use codex_utils_path_uri::PathUri; -use futures::StreamExt; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -pub struct RemoteFiles { - fs: Arc, - workspace: PathBuf, -} - -impl RemoteFiles { - pub fn new(fs: Arc, workspace: PathBuf) -> Self { - Self { fs, workspace } - } - - fn path(&self, name: &str) -> Result { - PathUri::from_host_native_path(self.workspace.join(name)) - .context("encode remote filesystem path") - } - - pub async fn write(&self, name: &str, bytes: Vec) -> Result<()> { - self.fs - .write_file(&self.path(name)?, bytes, WriteFileOptions::default(), None) - .await - .context("native file write failed") - } - - async fn read(&self, name: &str) -> Result> { - self.fs - .read_file(&self.path(name)?, ReadFileOptions::default(), None) - .await - .context("native file read failed") - } - - async fn streamed(&self, name: &str, limit: usize) -> Result<(Vec, bool, usize)> { - let mut stream = self.fs.read_file_stream(&self.path(name)?, None).await?; - let mut bytes = Vec::new(); - let mut chunks = 0; - while let Some(chunk) = stream.next().await { - let chunk = chunk.context("native stream read failed")?; - ensure!( - chunk.len() <= 1024 * 1024, - "native chunk exceeds pinned bound" - ); - chunks += 1; - let remaining = limit - bytes.len(); - bytes.extend_from_slice(&chunk[..remaining.min(chunk.len())]); - if chunk.len() > remaining { - // Native Drop schedules close; this result is not a close receipt. - return Ok((bytes, true, chunks)); - } - } - Ok((bytes, false, chunks)) - } - - async fn verify_stream(&self) -> Result { - let expected: Vec = (0..2 * 1024 * 1024 + 37) - .map(|index| (index % 251) as u8) - .collect(); - let (bytes, truncated, chunks) = self.streamed("stream-binary.bin", expected.len()).await?; - ensure!( - bytes == expected && !truncated && chunks >= 3, - "native multi-chunk bytes differ" - ); - let (prefix, truncated, _) = self.streamed("stream-binary.bin", 4096).await?; - ensure!( - prefix == expected[..4096] && truncated, - "native bounded prefix differs" - ); - let (empty, truncated, _) = self.streamed("stream-empty.bin", 0).await?; - ensure!( - empty.is_empty() && !truncated, - "native empty stream differs" - ); - Ok(json!({ - "bytes": bytes.len(), "sha256": format!("{:x}", Sha256::digest(&bytes)), - "chunks": chunks, "prefix_bytes": prefix.len(), "empty_bytes": empty.len(), - "close_receipt_verified": false, "snapshot_consistency_verified": false - })) - } - - pub async fn text(&self, name: &str) -> Result { - String::from_utf8(self.read(name).await?).context("native file is not UTF-8") - } - - pub async fn optional_text(&self, name: &str) -> Result> { - match self - .fs - .read_file(&self.path(name)?, ReadFileOptions::default(), None) - .await - { - Ok(bytes) => Ok(Some( - String::from_utf8(bytes).context("native checkpoint is not UTF-8")?, - )), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error).context("native checkpoint read failed"), - } - } - - pub async fn names(&self) -> Result> { - let mut entries = self - .fs - .read_directory(&self.path("")?, None) - .await - .context("native directory listing failed")?; - entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); - ensure!( - entries - .iter() - .any(|entry| entry.file_name == "shared-binary.bin" && entry.is_file), - "binary missing from native directory listing" - ); - Ok(entries.into_iter().map(|entry| entry.file_name).collect()) - } - - pub async fn idle(&self, phase: &str, previous: &Value) -> Result { - if phase == "first" { - self.write( - "stream-binary.bin", - (0..2 * 1024 * 1024 + 37) - .map(|index| (index % 251) as u8) - .collect(), - ) - .await?; - self.write("stream-empty.bin", Vec::new()).await?; - let seed = Uuid::now_v7(); - let bytes: Vec = (0..128 * 1024) - .map(|index| (index % 251) as u8 ^ seed.as_bytes()[index % 16]) - .collect(); - self.write("shared-binary.bin", bytes.clone()).await?; - ensure!( - self.read("shared-binary.bin").await? == bytes, - "idle binary round trip differs" - ); - } else { - self.verify_binary(&previous["files"]).await?; - let expected = previous["marker"] - .as_str() - .context("previous marker is missing")?; - ensure!( - self.text("first-marker.txt").await? == format!("{expected}\n"), - "cold file retention failed" - ); - } - let bytes = self.read("shared-binary.bin").await?; - let metadata = self - .fs - .get_metadata( - &self.path("shared-binary.bin")?, - GetMetadataOptions::default(), - None, - ) - .await - .context("native metadata failed")?; - ensure!( - bytes.len() == 128 * 1024 && metadata.size == bytes.len() as u64 && metadata.is_file, - "native binary metadata differs" - ); - Ok( - json!({"binary_bytes":bytes.len(),"binary_sha256":format!("{:x}",Sha256::digest(&bytes)),"metadata_size":metadata.size,"directory_names":self.names().await?,"stream":self.verify_stream().await?}), - ) - } - - pub async fn verify_binary(&self, proof: &Value) -> Result<()> { - let bytes = self.read("shared-binary.bin").await?; - ensure!(bytes.len() == 128 * 1024, "retained binary size differs"); - let metadata = self - .fs - .get_metadata( - &self.path("shared-binary.bin")?, - GetMetadataOptions::default(), - None, - ) - .await - .context("native binary metadata failed")?; - ensure!( - metadata.size == bytes.len() as u64 && metadata.is_file, - "native binary metadata differs" - ); - let expected = proof["binary_sha256"] - .as_str() - .context("binary proof hash is missing")?; - ensure!( - format!("{:x}", Sha256::digest(&bytes)) == expected, - "retained binary hash differs" - ); - self.verify_stream().await?; - Ok(()) - } -} diff --git a/services/agents-api/tests/native/shared_files/observations.rs b/services/agents-api/tests/native/shared_files/observations.rs deleted file mode 100644 index 98fa3e322..000000000 --- a/services/agents-api/tests/native/shared_files/observations.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::path::Path; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow, ensure}; -use codex_app_server_protocol::{ServerNotification, ThreadItem}; -use codex_shell_command::parse_command::extract_shell_command; -use serde_json::{Value, json}; -use tokio::time::sleep; - -#[derive(Clone, Default)] -struct Observed { - failure: Option<&'static str>, - turns_started: Vec<(String, String)>, - turns_completed: Vec<(String, String, Value)>, - commands_started: Vec<(String, String, String)>, - commands_completed: Vec<(String, String, Value)>, - answer: String, - events: Vec, - event_bytes: usize, - event_count: usize, -} - -impl Observed { - fn record(&mut self, notification: ServerNotification) -> Result<()> { - self.event_count += 1; - ensure!( - self.event_count <= 10_000, - "native event count exceeded fixture bound" - ); - let relevant = match ¬ification { - ServerNotification::TurnStarted(event) => { - self.turns_started - .push((event.thread_id.clone(), event.turn.id.clone())); - true - } - ServerNotification::TurnCompleted(event) => { - self.turns_completed.push(( - event.thread_id.clone(), - event.turn.id.clone(), - serde_json::to_value(&event.turn)?, - )); - true - } - ServerNotification::ItemStarted(event) => { - if let ThreadItem::CommandExecution { id, .. } = &event.item { - self.commands_started.push(( - event.thread_id.clone(), - event.turn_id.clone(), - id.clone(), - )); - true - } else { - false - } - } - ServerNotification::ItemCompleted(event) => match &event.item { - ThreadItem::CommandExecution { .. } => { - self.commands_completed.push(( - event.thread_id.clone(), - event.turn_id.clone(), - serde_json::to_value(&event.item)?, - )); - true - } - ThreadItem::AgentMessage { text, phase, .. } => { - let phase = serde_json::to_value(phase)?; - if phase.is_null() || phase == "final_answer" { - ensure!( - self.answer.len() + text.len() <= 256 * 1024, - "answer exceeded fixture bound" - ); - self.answer.push_str(text); - self.answer.push('\n'); - } - true - } - _ => false, - }, - _ => false, - }; - ensure!( - self.turns_started.len() <= 1 - && self.turns_completed.len() <= 1 - && self.commands_started.len() <= 1 - && self.commands_completed.len() <= 1, - "unexpected additional Turn or command" - ); - if relevant { - let value = serde_json::to_value(notification)?; - self.event_bytes += serde_json::to_vec(&value)?.len(); - ensure!( - self.event_bytes <= 4 * 1024 * 1024, - "native observations exceeded fixture byte bound" - ); - self.events.push(value); - } - Ok(()) - } -} - -pub struct Evidence { - pub answer: String, - pub command: Value, - pub events: Vec, -} - -#[derive(Clone, Default)] -pub struct Observations { - state: Arc>, -} - -impl Observations { - pub fn record(&self, notification: ServerNotification) -> Result<()> { - let mut state = self - .state - .lock() - .map_err(|_| anyhow!("observation lock poisoned"))?; - if state.failure.is_none() && state.record(notification).is_err() { - state.failure = Some("native observation bounds or counts failed"); - } - Ok(()) - } - - pub fn fail(&self, reason: &'static str) -> Result<()> { - self.state - .lock() - .map_err(|_| anyhow!("observation lock poisoned"))? - .failure = Some(reason); - Ok(()) - } - - fn snapshot(&self) -> Result { - Ok(self - .state - .lock() - .map_err(|_| anyhow!("observation lock poisoned"))? - .clone()) - } - - pub fn events(&self) -> Result> { - self.healthy()?; - Ok(self.snapshot()?.events) - } - - pub fn healthy(&self) -> Result<()> { - let state = self - .state - .lock() - .map_err(|_| anyhow!("observation lock poisoned"))?; - ensure!( - state.failure.is_none(), - "{}", - state.failure.unwrap_or("native observation failed") - ); - Ok(()) - } - - pub fn require_active(&self, thread: &str, turn: &str) -> Result<()> { - self.healthy()?; - let state = self.snapshot()?; - ensure!( - state.turns_started == vec![(thread.to_owned(), turn.to_owned())] - && state.turns_completed.is_empty(), - "native Turn is not observed active" - ); - ensure!( - state.commands_started.len() == 1 && state.commands_completed.is_empty(), - "native command is not observed active" - ); - ensure!( - state.commands_started[0].0 == thread && state.commands_started[0].1 == turn, - "active command belongs to another Turn" - ); - Ok(()) - } - - pub fn require_unfinished(&self) -> Result<()> { - self.healthy()?; - ensure!( - self.snapshot()?.turns_completed.is_empty(), - "native Turn completed before command heartbeat" - ); - Ok(()) - } - - pub async fn wait_active(&self, thread: &str, turn: &str) -> Result<()> { - loop { - self.healthy()?; - let state = self.snapshot()?; - ensure!( - state.turns_completed.is_empty() && state.commands_completed.is_empty(), - "command completed before active checkpoint" - ); - if state.turns_started.len() == 1 && state.commands_started.len() == 1 { - return self.require_active(thread, turn); - } - sleep(Duration::from_millis(25)).await; - } - } - - pub async fn wait_completed(&self) -> Result<()> { - loop { - self.healthy()?; - if !self.snapshot()?.turns_completed.is_empty() { - return Ok(()); - } - sleep(Duration::from_millis(100)).await; - } - } - - pub fn validate( - &self, - thread: &str, - turn: &str, - phase: &str, - workspace: &Path, - marker: &str, - history: &str, - ) -> Result { - self.healthy()?; - let state = self.snapshot()?; - ensure!( - state.turns_started == vec![(thread.to_owned(), turn.to_owned())] - && state.turns_completed.len() == 1, - "native Turn lifecycle missing" - ); - let completed = &state.turns_completed[0]; - ensure!( - completed.0 == thread && completed.1 == turn && completed.2["status"] == "completed", - "native Turn did not complete successfully" - ); - ensure!( - state.commands_started.len() == 1 && state.commands_completed.len() == 1, - "native command lifecycle missing" - ); - let started = &state.commands_started[0]; - let completed = &state.commands_completed[0]; - let item = &completed.2; - ensure!( - started.0 == thread - && started.1 == turn - && completed.0 == thread - && completed.1 == turn - && item["id"] == started.2, - "command lifecycle identities differ" - ); - let command = item["command"] - .as_str() - .context("native command text missing")?; - ensure!( - is_gate_command(command, phase), - "model executed an unexpected command" - ); - let cwd = workspace.to_str().context("workspace encoding")?; - ensure!( - item["cwd"] == cwd && item["exitCode"] == 7, - "native command cwd or exit differs" - ); - let output = item["aggregatedOutput"] - .as_str() - .context("native command output missing")?; - ensure!( - output.contains(marker) - && output.contains(&format!("remote-stdout:{phase}")) - && output.contains(&format!("remote-stderr:{phase}")), - "native command output observations missing" - ); - ensure!( - !command.contains(history) && !output.contains(history), - "prompt-only history leaked into command or files" - ); - ensure!( - state.answer.contains(marker) && state.answer.contains(history), - "native final answer did not recall marker and history" - ); - Ok(Evidence { - answer: state.answer, - command: json!({"id":item["id"],"command":command,"cwd":cwd,"aggregated_output":output,"exit_code":7,"started":true,"completed":true}), - events: state.events, - }) - } -} - -pub(super) fn is_gate_command(command: &str, phase: &str) -> bool { - let Some(argv) = shlex::split(command) else { - return false; - }; - argv == ["./shared-gate.sh", phase] - || extract_shell_command(&argv) - .is_some_and(|(_, script)| script == format!("./shared-gate.sh {phase}")) -} - -#[cfg(test)] -mod tests { - use super::is_gate_command; - - #[test] - fn exact_gate_accepts_native_presentation_only() { - assert!(is_gate_command("./shared-gate.sh first", "first")); - assert!(is_gate_command( - "/bin/bash -lc './shared-gate.sh first'", - "first" - )); - assert!(!is_gate_command( - "/bin/bash -lc './shared-gate.sh first; echo invented'", - "first" - )); - assert!(!is_gate_command("./shared-gate.sh fresh", "first")); - assert!(!is_gate_command( - "./shared-gate.sh first && echo invented", - "first" - )); - } -} diff --git a/services/agents-api/tests/native/shared_files/probe.rs b/services/agents-api/tests/native/shared_files/probe.rs deleted file mode 100644 index e09f7ad64..000000000 --- a/services/agents-api/tests/native/shared_files/probe.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result, ensure}; -use codex_exec_server::EnvironmentManager; -use serde_json::{Value, json}; -use tokio::time::{sleep, timeout}; -use uuid::Uuid; - -use crate::{cancellation, configuration, files, runtime}; - -const PHASE_TIMEOUT: Duration = Duration::from_secs(240); -// Match pinned codex arg0/src/lib.rs and async-utils/src/lib.rs for native futures. -const NATIVE_STACK_BYTES: usize = 16 * 1024 * 1024; - -pub fn main(allow_cancel: bool) -> std::process::ExitCode { - match run_native_runtime(allow_cancel) { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - // Display only the safe outer context, not upstream connection/auth error chains. - eprintln!("shared-files probe failed: {error}"); - std::process::ExitCode::FAILURE - } - } -} - -fn run_native_runtime(allow_cancel: bool) -> Result<()> { - std::thread::Builder::new() - .name("shared-files-main".to_owned()) - .stack_size(NATIVE_STACK_BYTES) - .spawn(move || { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .worker_threads(4) - .thread_stack_size(NATIVE_STACK_BYTES) - .build() - .context("native runtime construction failed")? - .block_on(run(allow_cancel)) - }) - .context("native main thread construction failed")? - .join() - .map_err(|_| anyhow::anyhow!("native main thread panicked"))? -} - -async fn run(allow_cancel: bool) -> Result<()> { - let phase = std::env::args().nth(1).context("phase is required")?; - ensure!( - matches!(phase.as_str(), "first" | "fresh") || (allow_cancel && phase == "cancel"), - "invalid phase" - ); - let root = configuration::proof_root( - &required_path("PARSAR_SHARED_FILES_ROOT")?, - &configuration::caller_state_root()?, - )?; - let workspace = required_path("PARSAR_SHARED_FILES_WORKSPACE")?; - ensure!( - !workspace.exists(), - "executor workspace exists on harness host" - ); - let binary = required_path("PARSAR_CODEX_BINARY")?; - ensure!(binary.is_file(), "native resource binary is missing"); - ensure!( - !std::env::var("PARSAR_PROBE_MODEL_KEY") - .unwrap_or_default() - .trim() - .is_empty(), - "explicit model key is missing" - ); - ensure!( - std::env::var_os("CODEX_API_KEY").is_none() && std::env::var_os("OPENAI_API_KEY").is_none(), - "ambient provider authentication is present" - ); - for name in [ - "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", - "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", - "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", - ] { - ensure!( - !std::env::var(name).unwrap_or_default().is_empty(), - "explicit Noise configuration is missing" - ); - } - tokio::fs::create_dir_all(root.join("harness")) - .await - .context("create harness directory")?; - let (client, manager) = runtime::Runtime::start(&root, binary).await?; - let result = timeout(PHASE_TIMEOUT, async { - ensure!( - manager.try_local_environment().is_none(), - "local fallback is configured" - ); - let environment = manager - .default_environment() - .context("remote Environment is absent")?; - ensure!(environment.is_remote(), "Environment is not remote"); - timeout(Duration::from_secs(45), environment.wait_until_ready()) - .await - .context("remote readiness timed out")? - .context("remote readiness failed")?; - let fs = files::RemoteFiles::new(environment.get_filesystem(), workspace.clone()); - if phase == "cancel" { - cancellation::exercise(&root, &workspace, &fs, &client).await - } else { - exercise(&root, &workspace, &phase, &fs, &client, &manager).await - } - }) - .await - .context("shared-files phase timed out") - .and_then(|result| result); - let result = async { - let mut proof = result?; - save(&root, &format!("{phase}-ready.json"), &proof).await?; - wait_checkpoint(&root.join(format!("{phase}-release")), &client).await?; - if phase == "cancel" { - cancellation::refresh(&mut proof, &client)?; - } - Ok::<_, anyhow::Error>(proof) - } - .await; - let stopped = client.shutdown().await; - drop(manager); - stopped?; - let mut proof = result?; - proof["shutdown_completed"] = json!(true); - save(&root, &format!("{phase}.json"), &proof).await -} - -async fn exercise( - root: &Path, - workspace: &Path, - phase: &str, - fs: &files::RemoteFiles, - client: &runtime::Runtime, - manager: &Arc, -) -> Result { - let previous: Value = if phase == "fresh" { - serde_json::from_slice( - &tokio::fs::read(root.join("first.json")) - .await - .context("read first proof")?, - ) - .context("decode first proof")? - } else { - Value::Null - }; - let marker = format!("shared-marker-{}", Uuid::now_v7()); - let history = if phase == "first" { - format!("history-only-{}", Uuid::now_v7()) - } else { - field(&previous, "history_value")?.to_owned() - }; - let mut file_proof = fs.idle(phase, &previous).await?; - let selection = json!([{"environmentId":"remote", "cwd":workspace}]); - let thread_response = if phase == "first" { - client - .request( - 1, - "thread/start", - json!({"cwd":workspace,"environments":selection,"ephemeral":false}), - ) - .await? - } else { - client - .request( - 1, - "thread/resume", - json!({"threadId":field(&previous,"native_thread_id")?,"cwd":workspace}), - ) - .await? - }; - let thread_id = field(&thread_response["thread"], "id")?.to_owned(); - if phase == "fresh" { - ensure!( - thread_id == field(&previous, "native_thread_id")?, - "cold resume changed thread identity" - ); - } - let recovery = if phase == "fresh" { - cancellation::recover(root, &thread_id, fs, client).await? - } else { - None - }; - let memory_instruction = if phase == "first" { - format!( - "Remember this prompt-only history value: {history}. Never put that value in a command, tool argument, or file." - ) - } else { - "Recall the prompt-only history value from our previous turn. It is not in any workspace file; do not search for it.".to_owned() - }; - let prompt = format!( - "{memory_instruction} Execute exactly one command: `./shared-gate.sh {phase}`. The `{phase}` argument is required; pass the entire command including this argument as the exec_command cmd value. Do not omit or change the argument. It waits on a bounded fixture gate and intentionally exits 7 after printing a new random marker, remote stdout and remote stderr. Let it finish; use native polling if needed, but never rerun it or execute any other command. Do not open the gate or read/write any files yourself. Then give one final answer containing both the exact newly printed marker and the prompt-only history value. Do not guess the marker or repair the intentional exit status." - ); - let started = client.request(2, "turn/start", json!({"threadId":thread_id,"input":[{"type":"text","text":prompt}],"environments":selection})).await?; - let turn_id = field(&started["turn"], "id")?.to_owned(); - let heartbeat = timeout(Duration::from_secs(150), async { - loop { - client.observations.require_unfinished()?; - if let Some(value) = fs.optional_text(&format!("{phase}.heartbeat")).await? { - ensure!(!value.trim().is_empty(), "empty active heartbeat"); - client - .observations - .wait_active(&thread_id, &turn_id) - .await?; - break Ok::<_, anyhow::Error>(value); - } - sleep(Duration::from_millis(100)).await; - } - }) - .await - .context("real command heartbeat timed out")??; - wait_checkpoint(&root.join(format!("{phase}-active-observed")), client).await?; - client.observations.require_active(&thread_id, &turn_id)?; - fs.verify_binary(&file_proof).await?; - let active_directory = fs.names().await?; - ensure!( - fs.optional_text(&format!("{phase}.release")) - .await? - .is_none(), - "remote gate was already released" - ); - fs.write( - &format!("{phase}-marker.txt"), - format!("{marker}\n").into_bytes(), - ) - .await?; - ensure!( - fs.text(&format!("{phase}-marker.txt")).await? == format!("{marker}\n"), - "active direct file round trip differs" - ); - fs.write(&format!("{phase}.release"), b"release\n".to_vec()) - .await?; - client.observations.wait_completed().await?; - let observation = client - .observations - .validate(&thread_id, &turn_id, phase, workspace, &marker, &history)?; - ensure!( - fs.text(&format!("{phase}.cwd")).await?.trim() - == workspace.to_str().context("workspace path encoding")?, - "remote command cwd differs" - ); - let artifact = fs.text(&format!("{phase}-artifact.txt")).await?; - ensure!( - artifact == format!("{marker}\n"), - "command artifact differs from direct file marker" - ); - let counts = fs.text("shared-gate-count").await?; - let expected = if phase == "first" { - vec!["first"] - } else if recovery.is_some() { - vec!["first", "cancel", "fresh"] - } else { - vec!["first", "fresh"] - }; - ensure!( - counts.lines().collect::>() == expected, - "real command gate ran more than once" - ); - fs.verify_binary(&file_proof).await?; - file_proof["active_heartbeat"] = json!(heartbeat); - file_proof["active_binary_verified"] = json!(true); - file_proof["active_directory_names"] = json!(active_directory); - file_proof["artifact"] = json!(artifact); - file_proof["directory_names"] = json!(fs.names().await?); - ensure!( - manager.try_local_environment().is_none(), - "local fallback appeared" - ); - let mut proof = json!({ - "phase":phase, - "native_thread_id":thread_id,"native_turn_id":turn_id,"marker":marker,"history_value":history, - "answer":observation.answer,"command":observation.command,"files":file_proof, - "turn_started_count":1,"turn_completed_count":1,"command_started_count":1,"command_completed_count":1, - "events":observation.events,"no_lagged_observed":true,"shutdown_completed":false, - }); - if let Some(recovery) = recovery { - proof["cancellation_recovery"] = recovery; - } - runtime::annotate(&mut proof); - Ok(proof) -} - -pub(super) async fn wait_checkpoint(path: &Path, client: &runtime::Runtime) -> Result<()> { - timeout(Duration::from_secs(90), async { - loop { - client.observations.healthy()?; - if tokio::fs::try_exists(path) - .await - .context("read host checkpoint")? - { - return Ok::<_, anyhow::Error>(()); - } - sleep(Duration::from_millis(100)).await; - } - }) - .await - .context("fixture checkpoint timed out")? -} - -fn required_path(name: &str) -> Result { - let path = PathBuf::from(std::env::var(name).with_context(|| format!("{name} is required"))?); - ensure!(path.is_absolute(), "{name} must be absolute"); - Ok(path) -} - -pub(super) fn field<'a>(value: &'a Value, key: &str) -> Result<&'a str> { - value[key] - .as_str() - .filter(|value| !value.is_empty()) - .with_context(|| format!("missing proof field {key}")) -} - -async fn save(root: &Path, name: &str, value: &Value) -> Result<()> { - let temporary = root.join(format!("{name}.tmp")); - tokio::fs::write( - &temporary, - serde_json::to_vec_pretty(value).context("encode proof")?, - ) - .await - .context("write proof")?; - tokio::fs::rename(temporary, root.join(name)) - .await - .context("publish proof") -} diff --git a/services/agents-api/tests/native/shared_files/raw_runtime.rs b/services/agents-api/tests/native/shared_files/raw_runtime.rs deleted file mode 100644 index d49cc4bdf..000000000 --- a/services/agents-api/tests/native/shared_files/raw_runtime.rs +++ /dev/null @@ -1,294 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow, bail, ensure}; -use codex_app_server::{ - AppServerRuntimeOptions, AppServerTransport, AppServerWebsocketAuthSettings, - PluginStartupTasks, RemoteControlStartupMode, - run_main_with_transport_options_and_environment_manager, -}; -use codex_app_server_client::{ - AppServerEvent, AppServerRequestHandle, RemoteAppServerClient, RemoteAppServerConnectArgs, - RemoteAppServerEndpoint, -}; -use codex_app_server_protocol::{ClientRequest, JSONRPCErrorError}; -use codex_arg0::Arg0DispatchPaths; -use codex_config::LoaderOverrides; -use codex_exec_server::EnvironmentManager; -use codex_protocol::protocol::SessionSource; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::CliConfigOverrides; -use serde_json::{Value, json}; -use tempfile::TempDir; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tokio::time::{sleep, timeout}; - -use super::{configuration, observations::Observations}; - -pub struct Runtime { - sender: AppServerRequestHandle, - pub observations: Observations, - stop: oneshot::Sender<()>, - drain: JoinHandle>, - runner: JoinHandle>, - socket_directory: TempDir, -} - -impl Runtime { - pub async fn start(root: &Path, binary: PathBuf) -> Result<(Self, Arc)> { - let home = root.join("harness/codex"); - ensure!( - std::env::var_os("CODEX_HOME").map(PathBuf::from).as_ref() == Some(&home), - "raw runner requires the isolated native history directory" - ); - tokio::fs::create_dir_all(&home) - .await - .context("create native history directory")?; - // The proof path can exceed Unix socket limits. This dedicated 0700 directory - // stays under the original caller's private root, independently of child HOME. - let socket_directory = tempfile::Builder::new() - .prefix("raw-files-") - .tempdir_in(configuration::caller_state_root()?) - .context("create private raw socket directory")?; - let socket_path = socket_directory.path().join("rpc.sock"); - ensure!( - socket_path.as_os_str().len() < 104, - "private raw socket path is too long" - ); - let absolute_socket = AbsolutePathBuf::from_absolute_path(&socket_path) - .context("invalid private raw socket path")?; - let raw_overrides = configuration::overrides() - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(); - let transport = AppServerTransport::UnixSocket { - socket_path: absolute_socket.clone(), - }; - let (publish, published) = oneshot::channel(); - let mut runner = tokio::spawn(async move { - run_main_with_transport_options_and_environment_manager( - Arg0DispatchPaths { - codex_self_exe: Some(binary), - ..Default::default() - }, - CliConfigOverrides { raw_overrides }, - LoaderOverrides { - ignore_user_config: true, - ignore_project_config: true, - ..Default::default() - }, - false, - false, - transport, - SessionSource::Exec, - AppServerWebsocketAuthSettings::default(), - AppServerRuntimeOptions { - plugin_startup_tasks: PluginStartupTasks::Skip, - remote_control_startup_mode: RemoteControlStartupMode::DisabledEphemeral, - install_shutdown_signal_handler: true, - ..Default::default() - }, - publish, - ) - .await - .context("raw native runner failed") - }); - let startup = async { - // Publication is not readiness: also wait for the socket and complete - // the maintained client's initialize/initialized exchange exactly once. - let manager = published.await.context("raw manager was not published")?; - while !tokio::fs::try_exists(&socket_path) - .await - .context("inspect raw socket")? - { - sleep(Duration::from_millis(20)).await; - } - let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: absolute_socket, - }, - client_name: "parsar_raw_files_probe".to_owned(), - client_version: "1".to_owned(), - experimental_api: true, - mcp_server_openai_form_elicitation: false, - opt_out_notification_methods: Vec::new(), - channel_capacity: 1024, - }) - .await - .context("raw initialize exchange failed")?; - Ok::<_, anyhow::Error>((client, manager)) - }; - let started = tokio::select! { - result = &mut runner => { - result.context("raw runner task failed during startup")??; - bail!("raw runner stopped during startup"); - } - result = timeout(Duration::from_secs(45), startup) => { - result.context("raw startup timed out").and_then(|result| result) - } - }; - let (mut client, manager) = match started { - Ok(started) => started, - Err(error) => { - // No initialized owner is returned on partial startup. The dedicated - // process exits after this bounded failure; no request is replayed. - runner.abort(); - let _ = runner.await; - return Err(error); - } - }; - let sender = AppServerRequestHandle::Remote(client.request_handle()); - let observations = Observations::default(); - let state = observations.clone(); - let (stop, mut stopped) = oneshot::channel(); - let drain = tokio::spawn(async move { - let result = async { - loop { - tokio::select! { - _ = &mut stopped => return Ok::<_, anyhow::Error>(()), - event = client.next_event() => match event { - Some(AppServerEvent::ServerNotification(notification)) => state.record(*notification)?, - Some(AppServerEvent::ServerRequest(request)) => { - state.fail("unexpected native client request")?; - timeout(Duration::from_secs(5), client.reject_server_request(request.id().clone(), JSONRPCErrorError { - code: -32601, - message: "Unexpected client request in shared files probe".to_owned(), - data: None, - })).await.context("native client request rejection timed out")? - .context("native client request rejection failed")?; - } - Some(AppServerEvent::Lagged { .. }) => state.fail("native Lagged event observed")?, - Some(AppServerEvent::Disconnected { .. }) | None => { - state.fail("raw native event stream closed early")?; - return Ok(()); - } - } - } - } - }.await; - // The native client has an unbounded event queue. Observation limits - // bound retained evidence only; this probe does not qualify backpressure. - let stopped = client - .shutdown() - .await - .context("raw native client shutdown failed"); - result.and(stopped) - }); - Ok(( - Self { - sender, - observations, - stop, - drain, - runner, - socket_directory, - }, - manager, - )) - } - - pub async fn request(&self, id: i64, method: &str, params: Value) -> Result { - self.observations.healthy()?; - ensure!( - !self.runner.is_finished(), - "raw native runner stopped before request" - ); - let request: ClientRequest = - serde_json::from_value(json!({"id":id,"method":method,"params":params})) - .context("native typed request parameters failed")?; - timeout(Duration::from_secs(60), self.sender.request(request)) - .await - .with_context(|| format!("native {method} timed out"))? - .with_context(|| format!("native {method} transport failed"))? - .map_err(|_| anyhow!("native {method} request failed")) - } - - pub async fn shutdown(self) -> Result<()> { - let Self { - sender, - observations, - stop, - mut drain, - mut runner, - socket_directory, - } = self; - drop(sender); - let _ = stop.send(()); - let client_stopped = match timeout(Duration::from_secs(15), &mut drain).await { - Ok(result) => result - .context("raw event drain task failed") - .and_then(|result| result), - Err(_) => { - drain.abort(); - let _ = drain.await; - Err(anyhow!("raw client shutdown exceeded fixture deadline")) - } - }; - let runner_stopped = if runner.is_finished() { - let result = (&mut runner).await.context("raw runner task failed")?; - result.and(Err(anyhow!("raw runner stopped before fixture shutdown"))) - } else { - // Closing the socket client does not stop the multi-client raw listener. - // This executable owns its whole process; SIGHUP requests native graceful - // shutdown, and success requires joining that runner, not just the client. - let signal = timeout( - Duration::from_secs(5), - tokio::process::Command::new("/bin/kill") - .args(["-HUP", &std::process::id().to_string()]) - .kill_on_drop(true) - .status(), - ) - .await - .context("raw shutdown signal timed out") - .and_then(|result| result.context("raw shutdown signal failed")) - .and_then(|status| { - ensure!(status.success(), "raw shutdown signal was rejected"); - Ok(()) - }); - match signal { - Ok(()) => match timeout(Duration::from_secs(30), &mut runner).await { - Ok(result) => result - .context("raw runner task failed") - .and_then(|result| result), - Err(_) => { - runner.abort(); - let _ = runner.await; - Err(anyhow!("raw runner shutdown exceeded fixture deadline")) - } - }, - Err(error) => { - runner.abort(); - let _ = runner.await; - Err(error) - } - } - }; - drop(socket_directory); - runner_stopped?; - client_stopped?; - observations.healthy() - } -} - -pub fn annotate(proof: &mut Value) { - proof["status"] = json!("raw_native_files_characterized"); - proof["transport"] = json!("raw_unix_socket"); - proof["initialize_completed"] = json!(true); - let cancellation_limit = if proof["cancellation"].is_object() - || proof["cancellation_recovery"].is_object() - { - "Cancellation observations cover this bounded fixture and its targeted native process only, not universal executor OS quiescence or stale-write fencing." - } else { - "Native cancellation is a required subsequent composition slice and is not exercised here." - }; - proof["limitations"] = json!([ - "The pinned native remote client has an unbounded consumer event queue. This finite workload does not qualify production memory or backpressure.", - "Readiness-gated output checks do not establish complete native early-output capture; that limitation remains deferred.", - "This private raw composition does not authorize production adoption, idle ownership or credential lifetime.", - cancellation_limit, - "Native runner shutdown may abort internal tasks; joining it is not proof of executor OS quiescence.", - "Direct native filesystem checks do not establish authorization, workspace confinement, public pagination or file_id semantics." - ]); -} diff --git a/services/agents-api/tests/native/shared_files/runtime.rs b/services/agents-api/tests/native/shared_files/runtime.rs deleted file mode 100644 index c73966e50..000000000 --- a/services/agents-api/tests/native/shared_files/runtime.rs +++ /dev/null @@ -1,182 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow}; -use codex_app_server::in_process::{ - self, InProcessClientSender, InProcessServerEvent, InProcessStartArgs, -}; -use codex_app_server_protocol::{ClientRequest, InitializeParams, JSONRPCErrorError}; -use codex_arg0::Arg0DispatchPaths; -use codex_config::{CloudConfigBundleLoader, LoaderOverrides, NoopThreadConfigLoader}; -use codex_core::config::{ConfigBuilder, ConfigOverrides}; -use codex_exec_server::{EnvironmentManager, ExecServerRuntimePaths}; -use codex_feedback::CodexFeedback; -use codex_protocol::protocol::SessionSource; -use serde_json::{Value, json}; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; -use tokio::time::timeout; - -use super::observations::Observations; - -async fn configuration( - root: &Path, - binary: PathBuf, -) -> Result<(InProcessStartArgs, Arc)> { - let home = root.join("harness/codex"); - tokio::fs::create_dir_all(&home) - .await - .context("create native history directory")?; - let overrides = super::configuration::overrides(); - let loader = LoaderOverrides { - ignore_user_config: true, - ignore_project_config: true, - ..LoaderOverrides::default() - }; - let paths = Arg0DispatchPaths { - codex_self_exe: Some(binary.clone()), - ..Arg0DispatchPaths::default() - }; - let config = Arc::new( - ConfigBuilder::default() - .codex_home(home) - .cli_overrides(overrides.clone()) - .loader_overrides(loader.clone()) - .harness_overrides(ConfigOverrides { - cwd: Some(root.join("harness")), - codex_self_exe: Some(binary.clone()), - ..ConfigOverrides::default() - }) - .build() - .await - .context("native configuration failed")?, - ); - let manager = Arc::new( - EnvironmentManager::from_env( - Some( - ExecServerRuntimePaths::new(binary, None) - .context("native resource paths failed")?, - ), - config.http_client_factory(), - ) - .await - .context("native Environment manager failed")?, - ); - let state_db = codex_rollout::state_db::try_init(config.as_ref()) - .await - .context("native history initialization failed")?; - let initialize: InitializeParams = serde_json::from_value(json!({"clientInfo":{"name":"parsar_shared_files_probe","version":"1"},"capabilities":{"experimentalApi":true}})).context("initialize parameters failed")?; - Ok(( - InProcessStartArgs { - arg0_paths: paths, - config, - cli_overrides: overrides, - loader_overrides: loader, - strict_config: false, - cloud_config_bundle: CloudConfigBundleLoader::default(), - thread_config_loader: Arc::new(NoopThreadConfigLoader), - feedback: CodexFeedback::new(), - log_db: None, - state_db: Some(state_db), - environment_manager: manager.clone(), - config_warnings: Vec::new(), - session_source: SessionSource::Exec, - enable_codex_api_key_env: false, - initialize, - channel_capacity: 1024, - }, - manager, - )) -} - -pub struct Runtime { - sender: InProcessClientSender, - pub observations: Observations, - stop: oneshot::Sender<()>, - drain: JoinHandle>, -} - -impl Runtime { - pub async fn start(root: &Path, binary: PathBuf) -> Result<(Self, Arc)> { - let (args, manager) = configuration(root, binary).await?; - let mut handle = timeout(Duration::from_secs(45), in_process::start(args)) - .await - .context("embedded app-server startup timed out")? - .context("embedded app-server startup failed")?; - let sender = handle.sender(); - let observations = Observations::default(); - let state = observations.clone(); - let (stop, mut stopped) = oneshot::channel(); - let drain = tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut stopped => break, - event = handle.next_event() => { - match event { - Some(InProcessServerEvent::ServerNotification(notification)) => { - state.record(*notification)?; - } - Some(InProcessServerEvent::ServerRequest(request)) => { - let _ = handle.fail_server_request(request.id().clone(), JSONRPCErrorError { code: -32601, message: "Unexpected client request in shared files probe".to_owned(), data: None }); - state.fail("unexpected native client request")?; - } - Some(InProcessServerEvent::Lagged { .. }) => { - state.fail("native Lagged event observed")?; - } - None => { - state.fail("native event stream closed early")?; - break; - } - } - } - } - } - handle - .shutdown() - .await - .context("embedded app-server shutdown failed") - }); - Ok(( - Self { - sender, - observations, - stop, - drain, - }, - manager, - )) - } - - pub async fn request(&self, id: i64, method: &str, params: Value) -> Result { - self.observations.healthy()?; - let request: ClientRequest = - serde_json::from_value(json!({"id":id,"method":method,"params":params})) - .context("native typed request parameters failed")?; - timeout(Duration::from_secs(60), self.sender.request(request)) - .await - .with_context(|| format!("native {method} timed out"))? - .with_context(|| format!("native {method} transport failed"))? - .map_err(|_| anyhow!("native {method} request failed")) - } - - pub async fn shutdown(self) -> Result<()> { - let observed = self.observations.clone(); - let _ = self.stop.send(()); - timeout(Duration::from_secs(50), self.drain) - .await - .context("embedded shutdown exceeded fixture deadline")? - .context("event drain task failed")??; - observed.healthy() - } -} - -pub fn annotate(proof: &mut Value) { - proof["status"] = json!("shared_native_files_characterized_with_blockers"); - proof["limitations"] = json!([ - "Pinned in-process queues can silently drop non-required notifications; only this bounded workflow's required observations were checked. Production lossless delivery remains blocked.", - "This is typed in-process embedding, not raw stdio compatibility or a production daemon integration.", - "Upstream shutdown may abort internal tasks; returned shutdown is not proof of executor OS quiescence.", - "Direct native filesystem checks do not establish authorization, workspace confinement, public pagination or file_id semantics." - ]); -} diff --git a/services/agents-api/tests/native/shared_files_probe.rs b/services/agents-api/tests/native/shared_files_probe.rs deleted file mode 100644 index 9e5b34f85..000000000 --- a/services/agents-api/tests/native/shared_files_probe.rs +++ /dev/null @@ -1,16 +0,0 @@ -#[path = "shared_files/cancellation.rs"] -mod cancellation; -#[path = "shared_files/configuration.rs"] -mod configuration; -#[path = "shared_files/files.rs"] -mod files; -#[path = "shared_files/observations.rs"] -mod observations; -#[path = "shared_files/probe.rs"] -mod probe; -#[path = "shared_files/runtime.rs"] -mod runtime; - -fn main() -> std::process::ExitCode { - probe::main(false) -} diff --git a/services/agents-api/tests/native/write/README.md b/services/agents-api/tests/native/write/README.md deleted file mode 100644 index eadde5aa6..000000000 --- a/services/agents-api/tests/native/write/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Private harness file-write qualification - -`TestNativeHarnessFileWrite` reuses the PostgreSQL registry, issued transport -credentials and Docker launcher fixture. It runs the built exact-pin harness, -connects its native EnvironmentManager over Noise and sends bounded binary input -to the existing private file socket. The executor runs the scoped installer under -the native Linux sandbox; no shell command or local file fallback performs writes. - -Set `PARSAR_CODEX_HARNESS_ARTIFACT`, `PARSAR_WRITE_HELPER_ARTIFACT`, -`PARSAR_EXECUTOR_PROOF_DIR`, `PARSAR_EXECUTOR_LAUNCHER`, `PARSAR_CODEX_BINARY` and -`PARSAR_PLACEMENT_EXECUTOR_IMAGE` to the qualified artifacts and private evidence -root. Use the existing PostgreSQL test configuration and run: - -```sh -go test ./services/agents-api/internal/store -run '^TestNativeHarnessFileWrite$' -count=1 -v -timeout=6m -``` - -Synthetic acceptance covers empty/binary/full 50 MiB writes, overwrite, hard-link -preservation, unsafe paths, oversized and incomplete input, caller detachment, -subsequent operation ownership and read-only rejection with configured write -selectors. Host-side bytes and inode observations are independent of the returned -commit receipt. Evidence records the harness digest and each payload digest. -Credentials never enter the record; teardown removes the owned containers and -transport credential files. Fixture placement grants only this Environment's -workspace/staging parent to the installer; it does not grant public admission. - -Run `TestNativePublicEnvironmentFiles` with a real model API and the same harness -artifact separately for unchanged model execution and public Files.list regression. -Native unit tests cover strict receipt decoding, chunk rejection, bounded input, -owner shutdown and unresolved native deadlines. These finite checks do not attest -durable mutation recovery or production replacement safety. diff --git a/services/agents-api/tests/native/write/probe.py b/services/agents-api/tests/native/write/probe.py deleted file mode 100644 index d25cf8b84..000000000 --- a/services/agents-api/tests/native/write/probe.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -"""Private raw harness qualification; synthetic bytes, no public admission.""" -import hashlib -import json -import os -from pathlib import Path -import socket -import subprocess -import time - -root = Path(os.environ['PARSAR_NATIVE_ENV_PROOF']) -local = Path(os.environ['PARSAR_WRITE_LOCAL']) -workspace = local / 'workspace' -ipc = Path(os.environ['PARSAR_CODEX_HARNESS_IPC_ROOT']) -artifact = os.environ['PARSAR_CODEX_HARNESS_ARTIFACT'] -observations = [] - - -def rpc(process, identifier, method, params): - process.stdin.write(json.dumps(dict(id=identifier, method=method, params=params)).encode() + b'\n') - process.stdin.flush() - while True: - line = process.stdout.readline() - if not line: - raise RuntimeError('harness ended before RPC response') - result = json.loads(line) - if result.get('id') == identifier: - if 'error' in result: - raise RuntimeError('native RPC rejected ' + method) - return result['result'] - - -def request(path, body, *, size=None, detach=False): - frame = dict(environment_id=os.environ['PARSAR_CODEX_HARNESS_ENVIRONMENT'], - path=path, operation='write', size_bytes=len(body) if size is None else size) - with socket.socket(socket.AF_UNIX) as client: - client.settimeout(65) - client.connect(str(ipc / 'files.sock')) - client.sendall(json.dumps(frame).encode() + b'\n' + body) - if detach: - return - with client.makefile('rb') as reader: - return json.loads(reader.readline(8192)) - - -def wait_file(path, expected): - deadline = time.monotonic() + 65 - while time.monotonic() < deadline: - if path.exists() and path.read_bytes() == expected: - return - time.sleep(.05) - raise AssertionError('detached write not independently observed') - - -def exercise(): - for size in (0, 256 * 1024 + 13, 50 * 1024 * 1024): - data = (bytes(range(256)) * (size // 256 + 1))[:size] - start = time.monotonic() - result = request('binary', data) - assert result == {'write': {'size_bytes': size, 'committed': True}}, result - assert (workspace / 'binary').read_bytes() == data - observations.append(dict(size=size, seconds=time.monotonic()-start, - sha256=hashlib.sha256(data).hexdigest(), response=result)) - original = local / 'staging' / 'original' - original.write_bytes(b'original') - os.link(original, workspace / 'linked') - assert request('linked', b'replacement') == {'write': {'size_bytes': 11, 'committed': True}} - assert original.read_bytes() == b'original' - assert (workspace / 'linked').read_bytes() == b'replacement' - assert original.stat().st_ino != (workspace / 'linked').stat().st_ino - for path in ('../outside', '/etc/escape', 'a//b'): - assert request(path, b'') == {'error': 'invalid_path'} - assert request('oversized', b'', size=50 * 1024 * 1024 + 1) == {'error': 'invalid_request'} - request('incomplete', b'x', size=3, detach=True) - # The next serial request establishes completion of pre-admission handling. - assert request('after-incomplete', b'') == {'write': {'size_bytes': 0, 'committed': True}} - assert not (workspace / 'incomplete').exists() - data = bytes(range(256)) * 4096 - request('detached', data, detach=True) - wait_file(workspace / 'detached', data) - assert request('after-detach', b'') == {'write': {'size_bytes': 0, 'committed': True}} - assert sorted(p.name for p in (local / 'staging').iterdir()) == ['original'] - - -for read_only in (False, True): - args = [artifact] - if read_only: - args.append('--workspace-read-only') - args += ['app-server', '--stdio'] - with (root / ('read-only.stderr' if read_only else 'write.stderr')).open('wb') as log: - process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) - try: - rpc(process, 1, 'initialize', {'clientInfo': {'name': 'write_probe', 'version': '1'}, 'capabilities': {'experimentalApi': True}}) - process.stdin.write(b'{"method":"initialized"}\n') - process.stdin.flush() - rpc(process, 2, 'environment/info', {'environmentId': 'remote'}) - if read_only: - assert request('read-only-denied', b'') == {'error': 'unsupported'} - assert not (workspace / 'read-only-denied').exists() - else: - exercise() - process.stdin.close() - assert process.wait(timeout=10) == 0 - finally: - if process.poll() is None: - process.kill() - process.wait(timeout=10) - assert not ipc.exists() - -(root / 'write-native.json').write_text(json.dumps(dict( - artifact_sha256=hashlib.sha256(Path(artifact).read_bytes()).hexdigest(), - synthetic=True, observations=observations, - hard_link_preserved=True, incomplete_not_dispatched=True, - caller_detach_retained=True, read_only_denied=True, - public_admission=False), indent=2) + '\n') diff --git a/services/agents-api/tests/official_e2b_v1.py b/services/agents-api/tests/official_e2b_v1.py deleted file mode 100644 index f16773063..000000000 --- a/services/agents-api/tests/official_e2b_v1.py +++ /dev/null @@ -1,532 +0,0 @@ -"""Opt-in standalone acceptance using actual E2B, native harnesses and model APIs. - -Pass one private operator JSON configuration path. Provider file/command calls in -this fixture observe effects or inject faults; public execution and Files always -use the deployed Core and daemon. No synthetic model server is provided. -""" -import base64 -import hashlib -import importlib.metadata -import json -import os -from pathlib import Path -import secrets -import subprocess -import sys -import tempfile -import time -import traceback -import uuid - -import httpx2 -from e2b import Sandbox, SandboxQuery -from openai import OpenAI - -from official_environment_files import verify_environment_files, verify_file_tenant_isolation -from official_session_artifacts import verify_session_artifacts - -config = json.loads(Path(sys.argv[1]).read_text()) -if config.get('verify_initial_files') and not config.get('verify_environment_templates'): - raise ValueError('Initial-file acceptance requires verify_environment_templates') -if config.get('verify_environment_setup') and not config.get('verify_initial_files'): - raise ValueError('Setup acceptance requires verify_initial_files') -if config.get('verify_system_packages') and not config.get('verify_environment_setup'): - raise ValueError('System-package acceptance requires verify_environment_setup') -if config.get('verify_initialization_restart') and not config.get('verify_initial_files'): - raise ValueError('Initialization restart acceptance requires verify_initial_files') -root = Path(config['proof_root']) -package = Path(config['package']) -root.mkdir(parents=True, exist_ok=True) -run = Path(tempfile.mkdtemp(prefix=config['engine'] + '-', dir=root)) -run.chmod(0o700) -pin = json.loads((package / 'upstream.json').read_text()) -distribution = importlib.metadata.distribution('openai') -assert distribution.version == pin['sdk_version'] -assert json.loads(distribution.read_text('direct_url.json'))['vcs_info']['commit_id'] == pin['commit'] -e2b_key = Path(config['e2b_key_file']).read_text().strip() -model_key = Path(config['model_key_file']).read_text().strip() -tokens = [secrets.token_hex(32), secrets.token_hex(32)] -provider = str(uuid.uuid4()) -created = [] -public_templates = [] -sources = [] -cloud = {} -handles = [] -process = None -record = {'engine': config['engine'], 'model': config['model'], 'template': config['template'], - 'started': time.time(), 'checks': [], 'provider': provider, - 'core_sha256': hashlib.sha256((package / 'bin/agents-api').read_bytes()).hexdigest(), - 'protocol': pin, 'real_e2b': True, 'real_model': True} -base = 'http://127.0.0.1:' + str(config['port']) -public = config['core_public_url'].rstrip('/') -http = httpx2.Client(trust_env=False, timeout=360) -client = OpenAI(base_url=base + '/v1', api_key=tokens[0], max_retries=0, - _strict_response_validation=True, http_client=http) -foreign = OpenAI(base_url=base + '/v1', api_key=tokens[1], max_retries=0, - _strict_response_validation=True, http_client=http) -sessions, files = client.beta.agents.sessions, client.beta.agents.environments.files -headers = {'Authorization': 'Bearer ' + tokens[0], 'OpenAI-Beta': 'agents=v1'} - - -def private(name, value): - path = run / name - path.write_text(json.dumps(value)) - path.chmod(0o600) - return str(path) - - -keys = [dict(token_sha256=hashlib.sha256(token.encode()).hexdigest(), tenant_id=str(uuid.uuid4()), - organization_id='e2b-acceptance', project_id=provider + '-' + str(i), - subject_kind='user', subject_id='caller-' + str(i)) for i, token in enumerate(tokens)] -env = {'HOME': str(Path.home()), 'PATH': '/usr/bin:/bin', 'PARSAR_HOME': str(run / 'state'), - 'AGENTS_API_DATABASE_URL': Path(config['database_file']).read_text().strip(), - 'AGENTS_API_KEYS_FILE': private('keys.json', keys), 'AGENTS_API_ADDR': '127.0.0.1:' + str(config['port']), - 'AGENTS_API_DAEMON_WS_URL': public.replace('https://', 'wss://') + '/api/v1/agent-daemon/ws', - 'AGENTS_API_ENGINE': config['engine'], - 'AGENTS_API_EXECUTION_OPTIONS_FILE': config['options_file'], - 'AGENTS_API_MANAGED_RUNTIMES_FILE': private('managed.json', {'core_url': public + '/api/v1', - 'default_provider': provider, 'e2b': {provider: {'api_key_file': config['e2b_key_file'], - 'template': config['template'], 'lease_seconds': 7200}}})} -if config.get('verify_initial_files'): - key_path = run / 'initial-file-encryption.key' - key_path.write_text(base64.b64encode(secrets.token_bytes(32)).decode()) - key_path.chmod(0o600) - env['AGENTS_API_CREDENTIAL_KEY_FILE'] = str(key_path) -if os.getenv('HTTPS_PROXY'): - env['HTTPS_PROXY'] = os.environ['HTTPS_PROXY'] - - -def until(fn, timeout=120): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = fn() - if result: - return result - time.sleep(.3) - raise AssertionError('Timed out: ' + fn.__name__) - - -def start(): - global process - output = (run / ('core-' + str(len(handles)) + '.log')).open('w') - handles.append(output) - process = subprocess.Popen([str(package / 'bin/agents-api')], cwd=package, env=env, - stdout=output, stderr=subprocess.STDOUT) - def ready(): - assert process.poll() is None, 'Core exited' - try: - return http.get(base + '/v1/agents/sessions', headers=headers).status_code == 200 - except httpx2.TransportError: - return False - until(ready, 30) - - -def stop(crash=False): - global process - if process and process.poll() is None: - process.kill() if crash else process.terminate() - process.wait(timeout=45) - process = None - - -def owned(): - pages = Sandbox.list(query=SandboxQuery(metadata={'io.parsar.agents-api.installation': provider}), api_key=e2b_key) - result = [] - while pages.has_next: - result.extend(pages.next_items()) - return result - - -def runtime(eid): - if eid not in cloud: - matching = [a for a in owned() if a.metadata.get('io.parsar.agents-api.environment') == eid] - assert len(matching) == 1, 'Missing or duplicate owned E2B VM' - cloud[eid] = Sandbox.connect(matching[0].sandbox_id, timeout=7200, api_key=e2b_key) - return cloud[eid] - - -def read(vm, path): - return vm.files.read(path, user='runtime', format='bytes') - - -def exists(vm, path): - return vm.files.exists(path, user='runtime') - - -def upload(eid, path, data): - if isinstance(data, str): - data = data.encode() - result = files.create(eid, type='inline', path=path, data=base64.b64encode(data).decode()) - assert result.size_bytes == len(data) - return len(data) - - -def message(text): - return {'type': 'agent.session.input.message', 'input': [{'role': 'user', 'content': [{'type': 'input_text', 'text': text}]}]} - - -def waitturn(sid, n, status='completed'): - def finished(): - turns = sessions.turns.list(sid, limit=100, order='asc').data - if len(turns) < n: - return False - turn = turns[n - 1] - if turn.status in ['completed', 'cancelled', 'failed']: - assert turn.status == status, ('Unexpected terminal Turn', turn.to_dict()) - return turn - return False - return until(finished, 240) - - -def prompt(sid, text, n): - with sessions.events.stream(sid, timeout=300) as stream: - sessions.events.create(sid, events=[message(text)], idempotency_key='prompt-' + str(n)) - types = [] - for event in stream: - types.append(event.type) - if event.type == 'agent.session.failed' or (event.type == 'agent.session.idle' and 'agent.session.turn.completed' in types): - break - assert 'agent.session.turn.completed' in types, types - assert types.index('agent.session.turn.created') < types.index('agent.session.turn.completed') - assert types[-1] == 'agent.session.idle', types - return waitturn(sid, n) - - -def connected(eid): - return until(lambda: client.beta.agents.environments.retrieve(eid).status == 'connected', - 300 if config.get('verify_system_packages') else 120) - - -def native_id(sid): - sql = "SELECT native_session_id FROM session_devices WHERE session_id='" + sid + "'" - return subprocess.check_output(config['psql_command'] + ['-At', '-c', sql], text=True).strip() - - -def restart_runtime(vm): - vm.commands.run('pkill -KILL -u 1000 || true', user='root') - script = '''import json,subprocess -from pathlib import Path -root=Path('/root/.parsar/e2b');r=json.loads((root/'ready.json').read_text()) -e=json.loads(Path('/etc/parsar-runtime-env.json').read_text()) -e.update(PATH='/usr/local/bin:/usr/bin:/bin',PARSAR_RUNTIME_ENVIRONMENT_ID=r['EnvironmentID'],PARSAR_RUNTIME_SESSION_ID=r['session_id'],PARSAR_RUNTIME_NETWORK_ACCESS='enabled') -f=open('/home/runtime/.parsar/parsar-daemon/default/restarted.log','ab') -subprocess.Popen(['/usr/local/bin/parsar-daemon','connect','--profile','default'],cwd='/environment/workspace',env=e,user=1000,group=1000,extra_groups=[],start_new_session=True,stdin=subprocess.DEVNULL,stdout=f,stderr=f,umask=0o077) -''' - vm.files.write('/root/.parsar/e2b/restart-proof.py', script, user='root') - vm.commands.run('/usr/bin/python3 /root/.parsar/e2b/restart-proof.py', user='root') - - -def check(name): - record['checks'].append(name) - print(name, flush=True) - - -try: - with (run / 'migrate.log').open('w') as output: - subprocess.run([str(package / 'bin/agents-api-migrate')], cwd=package, env=env, - stdout=output, stderr=subprocess.STDOUT, check=True) - start() - for authorization in [None, 'Bearer invalid-e2b-key']: - h = {'OpenAI-Beta': 'agents=v1'} - if authorization: - h['Authorization'] = authorization - assert http.get(base + '/v1/agents/sessions', headers=h).status_code == 401 - check('independent_deployment_and_authentication') - agent = {'model': config['model'], 'instructions': 'Run the exact requested native shell commands. Never modify supplied scripts or repeat interrupted commands. Preserve conversation history.'} - environment = {'type': 'openai_hosted'} - if config.get('verify_environment_templates'): - from official_environment_templates import verify_environment_templates, verify_template_session_rejections - enabled_template, disabled_template = verify_environment_templates(client, foreign, http) - public_templates.extend([enabled_template, disabled_template]) - verify_template_session_rejections(client, foreign, http, agent, enabled_template, disabled_template) - if config.get('verify_system_packages'): - from official_environment_setup import verify_system_package_configuration - verify_system_package_configuration(client, http) - environment['environment_template_id'] = enabled_template - check('template_sdk_http_crud_pagination_redaction_and_tenant_isolation') - initial_expected = {} - if config.get('verify_initial_files'): - from official_environment_initial_files import initial_files, verify_initial_snapshot, assert_initial_bytes_script - environment, initial_source, initial_expected = initial_files(client, foreign, http, agent, enabled_template) - sources.append(initial_source) - if config.get('verify_environment_setup'): - from official_environment_setup import setup_configuration, attach_setup, verify_setup_metadata, native_setup_script - setup, setup_marker = setup_configuration(system_packages=config.get('verify_system_packages', False)) - environment = attach_setup(client, foreign, http, environment, setup, enabled_template, network='enabled') - session = sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}) - created.append(session.id) - if config.get('verify_environment_setup'): - verify_setup_metadata(client, session, setup) - eid = session.environment.id - if initial_expected: - verify_initial_snapshot(client, http, session, initial_expected, initial_source) - sources.remove(initial_source) - assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id - if public_templates: - api = client.beta.agents.environments.templates - api.update(enabled_template, network={'access': 'disabled'}) - assert sessions.retrieve(session.id).environment.network.access == 'enabled' - assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id - api.delete(enabled_template) - public_templates.remove(enabled_template) - assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id - changed = {**environment, 'network': {'access': 'enabled'}} - conflict = http.post(base + '/v1/agents/sessions', headers={**headers, 'Idempotency-Key': 'idle'}, json={'agent': agent, 'environment': changed}) - assert conflict.status_code == 409 - check('template_snapshot_and_creation_retry_survive_update_and_delete') - connected(eid) - vm = runtime(eid) - expected_init = Path(__file__).parents[1] / 'deploy/e2b/init.py' - deployed_init = vm.files.read('/opt/parsar-e2b/init.py', user='root', format='bytes') - assert deployed_init == expected_init.read_bytes(), 'Template bootstrap differs from this checkout' - record['bootstrap_sha256'] = hashlib.sha256(deployed_init).hexdigest() - protection = vm.commands.run("""python3 - <<'CHECK' -import os,subprocess -for path in ['/usr/local', '/usr/local/bin', '/usr/local/bin/parsar-daemon', - '/opt/parsar-e2b', '/opt/parsar-e2b/init.py', '/usr/bin/envd', - '/etc/inittab', '/etc/init.d/rcS']: - if path == '/etc/init.d/rcS' and not os.path.exists(path): - continue - stat = os.stat(path) - assert stat.st_uid == 0 and stat.st_mode & 0o022 == 0, path - assert not os.access(path, os.W_OK), path -result = subprocess.run(['su', 'user', '-c', 'id -u'], input='', text=True, capture_output=True, timeout=8) -assert result.returncode != 0 -print('protected') -CHECK""", user='runtime') - assert protection.stdout == 'protected\n' - check('actual_runtime_code_ownership_and_privileged_account_denial') - if config.get('verify_system_packages'): - seed = vm.commands.run("""python3 -I -S - <<'CHECK' -from pathlib import Path -import hashlib,json,os -root = Path('/opt/agents-runtime') -for path in [root, root/'system-root.tar.gz', root/'system-root.json', Path('/usr/local/bin/agents-api-tool-root')]: - stat = path.stat() - assert stat.st_uid == 0 and stat.st_mode & 0o022 == 0 - assert not os.access(path, os.W_OK) -assert Path('/usr/local/bin/agents-api-tool-root').stat().st_mode & 0o777 == 0o555 -with (root/'system-root.tar.gz').open('rb') as stream: - digest = hashlib.file_digest(stream, 'sha256').hexdigest() -assert json.loads((root/'system-root.json').read_text()) == { - 'version': 1, 'sha256': digest, 'size_bytes': (root/'system-root.tar.gz').stat().st_size} -print(digest) -CHECK""", user='runtime') - record['system_seed_sha256'] = seed.stdout.strip() - check('finalized_system_seed_and_launcher_are_immutable_and_verified') - - for resource in ['/agents/sessions/' + session.id, '/agents/environments/' + eid]: - assert http.get(base + '/v1' + resource, headers={**headers, 'Authorization': 'Bearer ' + tokens[1]}).status_code == 404 - marker, memory = secrets.token_hex(24), secrets.token_hex(24) - expected_files = {p: len(body) for p, body in initial_expected.items()} - if config.get('verify_environment_setup'): - expected_files.update({'/workspace/setup-once': 11, '/workspace/setup-version': 6}) - if config.get('verify_system_packages'): - for path in ['/workspace/system-library.c', '/workspace/system-library']: - expected_files[path] = len(read(vm, path)) - for name, data in [('input.txt', marker.encode()), ('binary.bin', bytes(range(256))), ('empty', b'')]: - expected_files['/workspace/' + name] = upload(eid, '/workspace/' + name, data) - source = client.files.create(file=('source.bin', b'source-bytes\x00\xff'), purpose='user_data') - sources.append(source.id) - files.create(eid, type='file_id', file_id=source.id, path='/workspace/source.bin') - expected_files['/workspace/source.bin'] = len(b'source-bytes\x00\xff') - _, continuation = verify_environment_files(client, http, eid, '/workspace', expected_files) - verify_file_tenant_isolation(client, foreign, http, eid, '/workspace', continuation, list(expected_files)) - check('public_session_binding_files_bytes_sort_pages_and_tenant_isolation') - script = 'from pathlib import Path\np=Path("/workspace/outputs");p.mkdir(exist_ok=True)\n(p/"a.bin").write_bytes(Path("/workspace/binary.bin").read_bytes());(p/"empty").write_bytes(b"")\nprint(Path("/workspace/input.txt").read_text())\n' - if initial_expected: - script = 'from pathlib import Path\n' + assert_initial_bytes_script(initial_expected) + script - if config.get('verify_environment_setup'): - script = native_setup_script(setup_marker, system_packages=config.get('verify_system_packages', False)) + script - execution_prompt = 'Run exactly `python3 /workspace/publish.py`.' - if config.get('verify_system_packages') and config['engine'] == 'codex': - script += '''import sys -assert str(Path.cwd()) == sys.argv[1] -Path('/workspace/cwd-' + Path.cwd().name).write_text(str(Path.cwd())) -''' - execution_prompt = ( - 'Make two separate native shell tool calls. Set the tool workdir parameter; do not use cd. ' - 'First use workdir /environment/workspace with exactly ' - '`python3 /workspace/publish.py /environment/workspace`. ' - 'Then use workdir /environment/workspace/setup-sub with exactly ' - '`python3 /workspace/publish.py /environment/workspace/setup-sub`. Do not modify the script.') - upload(eid, '/workspace/publish.py', script) - first = prompt(session.id, execution_prompt + ' Remember this conversation-only marker: ' + memory, 1) - if config.get('verify_system_packages') and config['engine'] == 'codex': - commands = [item for item in sessions.items.list(session.id, limit=100).data - if item.type == 'command_execution' and item.turn_id == first.id] - for cwd in ['/environment/workspace', '/environment/workspace/setup-sub']: - assert read(vm, '/workspace/cwd-' + Path(cwd).name).decode() == cwd - assert any(item.cwd == cwd and item.exit_code == 0 for item in commands), cwd - check('real_native_default_workspace_and_subdirectory_preserved') - identity = native_id(session.id) - assert identity - expected_artifacts = {first.id: {'/workspace/outputs/a.bin': bytes(range(256)), '/workspace/outputs/empty': b''}} - verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) - committed = {item.id: item.to_dict() for item in sessions.items.list(session.id, limit=100).data} - check('real_native_execution_and_immutable_artifacts_sdk_http') - if initial_expected: - check('template_initial_files_exact_native_bytes_and_frozen_source_deletion') - upload(eid, '/workspace/initial-inline.bin', b'retained-user-change') - if config.get('verify_environment_setup'): - check('template_real_registry_packages_ordered_setup_and_native_visibility') - upload(eid, '/workspace/setup-once', b'preserved-setup-change') - # The native isolation script is the same actual-tool probe used to qualify all profiles. - history = config['native_history_root'] + '/e2b-isolation-canary' - vm.files.write(history, 'synthetic-private-history', user='runtime') - vm.files.write('/environment/staging/canary', 'synthetic-private-staging', user='runtime') - auth = json.loads(vm.files.read('/home/runtime/.parsar/parsar-daemon/default/auth.json', user='runtime')) - fixture = {'outer_pid_namespace': vm.commands.run('readlink /proc/self/ns/pid', user='root').stdout.strip(), - 'history_path': history, - 'secret_hashes': [hashlib.sha256(value.encode()).hexdigest() for value in [model_key, auth['runner_credential']]]} - upload(eid, '/workspace/isolation-fixture.json', json.dumps(fixture)) - isolation = Path(__file__).with_name('e2b_native_isolation.py').read_text() - upload(eid, '/workspace/isolation.py', isolation) - second = prompt(session.id, 'Run exactly `python3 /workspace/isolation.py`. Do not modify it.', 2) - assert json.loads(read(vm, '/workspace/isolation-result.json'))['passed'] - expected_artifacts[second.id] = expected_artifacts[first.id] - verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) - check('real_native_credential_history_process_and_envd_isolation') - long_script = 'from pathlib import Path\nimport os,time\np=Path("/workspace");f=(p/"starts").open("a");f.write("started\\n");f.flush();os.fsync(f.fileno());f.close()\nwhile True:\n (p/"heartbeat").write_text(str(time.time_ns()))\n time.sleep(.2)\n' - upload(eid, '/workspace/long.py', long_script) - sessions.events.create(session.id, events=[message('Run exactly `python3 /workspace/long.py` and wait. Do not background it.')], idempotency_key='cancel-work') - until(lambda: exists(vm, '/workspace/heartbeat')) - for _ in range(2): - sessions.events.create(session.id, events=[{'type': 'agent.session.input.cancel'}], idempotency_key='cancel') - waitturn(session.id, 3, 'cancelled') - heartbeat = read(vm, '/workspace/heartbeat') - time.sleep(2) - assert read(vm, '/workspace/heartbeat') == heartbeat - verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) - check('public_cancel_retry_stops_effects_without_publishing_cancelled_outputs') - for number, fault in [(4, 'core'), (5, 'runtime')]: - request = [message('Run exactly `python3 /workspace/long.py` once and wait. Do not restart it.')] - before = read(vm, '/workspace/starts') - sessions.events.create(session.id, events=request, idempotency_key='crash-' + fault) - until(lambda: read(vm, '/workspace/starts') != before) - count = read(vm, '/workspace/starts') - if fault == 'core': - stop(crash=True) - start() - else: - restart_runtime(vm) - waitturn(session.id, number, 'failed') - connected(eid) - def stable(): - value = read(vm, '/workspace/heartbeat') - time.sleep(.6) - return value if read(vm, '/workspace/heartbeat') == value else False - stopped = until(stable, 45) - sessions.events.create(session.id, events=request, idempotency_key='crash-' + fault) - time.sleep(1) - assert read(vm, '/workspace/starts') == count and read(vm, '/workspace/heartbeat') == stopped - assert len(sessions.turns.list(session.id).data) == number - assert native_id(session.id) == identity - current = {item.id: item.to_dict() for item in sessions.items.list(session.id, limit=100).data} - assert all(current[key] == value for key, value in committed.items()) - verify_session_artifacts(client, foreign, http, session.id, eid, expected_artifacts) - check(fault + '_crash_queries_history_artifacts_and_no_automatic_or_retry_replay') - prompt(session.id, 'Reply with the conversation-only marker I asked you to remember. Do not run any tools or previous commands.', 6) - answers = [item for item in sessions.items.list(session.id, order='asc', limit=100).data if item.type == 'message' and item.role == 'assistant'] - assert memory in ''.join(part.text for part in answers[-1].content if part.type == 'output_text') - assert native_id(session.id) == identity - check('same_native_history_continues_after_core_and_runtime_recovery') - disabled_environment = {'type': 'openai_hosted', 'network': {'access': 'disabled'}} - if public_templates: - disabled_environment = {'type': 'openai_hosted', 'environment_template_id': disabled_template} - inline_expected = {} - if config.get('verify_initial_files'): - disabled_environment, inline_source, inline_expected = initial_files(client, foreign, http, agent) - sources.append(inline_source) - assert read(vm, '/workspace/initial-inline.bin') == b'retained-user-change' - check('recovery_preserves_user_changes_without_reinstalling_initial_files') - if config.get('verify_environment_setup'): - assert read(vm, '/workspace/setup-once') == b'preserved-setup-change' - inline_setup, inline_setup_marker = setup_configuration(system_packages=config.get('verify_system_packages', False)) - disabled_environment = attach_setup(client, foreign, http, disabled_environment, inline_setup) - check('recovery_does_not_repeat_completed_setup') - disabled = sessions.create(agent=agent, environment=disabled_environment) - if inline_expected: - verify_initial_snapshot(client, http, disabled, inline_expected, inline_source) - sources.remove(inline_source) - assert disabled.environment.id != eid - assert disabled.environment.network.access == 'disabled' - if public_templates: - api.delete(disabled_template) - public_templates.remove(disabled_template) - check('template_inheritance_and_distinct_environment_ownership') - created.append(disabled.id) - connected(disabled.environment.id) - restricted = runtime(disabled.environment.id) - network_script = 'import urllib.request,urllib.error,json\ntry:\n urllib.request.urlopen("https://api.moonshot.cn/v1/models",timeout=8)\nexcept urllib.error.HTTPError as e:\n assert e.code==403,e.code\nexcept (urllib.error.URLError,PermissionError,TimeoutError):pass\nelse:raise AssertionError("native network was allowed")\nopen("/workspace/network-result.json","w").write(json.dumps({"blocked":True}))\n' - if inline_expected: - network_script = 'from pathlib import Path\n' + assert_initial_bytes_script(inline_expected) + network_script - if config.get('verify_environment_setup'): - verify_setup_metadata(client, disabled, inline_setup) - network_script = native_setup_script(inline_setup_marker, system_packages=config.get('verify_system_packages', False)) + network_script - upload(disabled.environment.id, '/workspace/network.py', network_script) - prompt(disabled.id, 'Run exactly `python3 /workspace/network.py`. Do not modify it.', 1) - assert json.loads(read(restricted, '/workspace/network-result.json')) == {'blocked': True} - check('real_model_execution_with_disabled_native_tool_network') - if inline_expected: - check('inline_initial_files_exact_native_bytes_and_frozen_source_deletion') - if config.get('verify_initialization_restart'): - interrupted = sessions.create(agent=agent, environment={'type': 'openai_hosted', 'files': [ - {'type': 'inline', 'path': '/workspace/step-' + str(i), 'data': base64.b64encode(b'startup-data').decode()} - for i in range(3)]}, input='Write /workspace/should-not-run containing executed.') - created.append(interrupted.id) - sql = "SELECT a.initialization FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id WHERE e.session_id='" + interrupted.id + "'" - until(lambda: subprocess.check_output(config['psql_command'] + ['-At', '-c', sql], text=True).strip() == 'running') - response = http.get(base + '/v1/agents/environments/' + interrupted.environment.id + '/files', headers=headers) - assert response.status_code in (409, 503), response.status_code - assert sessions.turns.list(interrupted.id).data == [] and not native_id(interrupted.id) - stop(crash=True) - start() - until(lambda: client.beta.agents.environments.retrieve(interrupted.environment.id).status == 'failed') - assert sessions.turns.list(interrupted.id).data == [] and not native_id(interrupted.id) - check('actual_core_restart_during_initialization_fails_without_native_execution_or_replay') - record['passed'] = True -except BaseException: - record['passed'] = False - record['failure'] = traceback.format_exc() -finally: - cleanup_errors = [] - if process is not None and process.poll() is None: - for template_id in public_templates: - try: - client.beta.agents.environments.templates.delete(template_id) - except Exception: - cleanup_errors.append('template_delete_failed') - for source_id in sources: - try: - client.files.delete(source_id) - except Exception: - cleanup_errors.append('source_delete_failed') - for sid in created: - try: - sessions.delete(sid) - except Exception: - cleanup_errors.append('public_delete_failed') - try: - until(lambda: not owned(), 90) - except Exception: - cleanup_errors.append('owned_cleanup_not_confirmed') - stop() - for handle in handles: - handle.close() - # A failed deployment cannot leave billable instances behind. Record fallback - # reclamation separately so it cannot masquerade as passing Core cleanup. - for allocation in owned(): - Sandbox.kill(allocation.sandbox_id, api_key=e2b_key) - cleanup_errors.append('direct_cleanup_required') - record.update(cleanup_errors=cleanup_errors, elapsed=time.time() - record['started']) - for path in run.iterdir(): - if path.is_file(): - text = path.read_text() - for secret in [e2b_key, model_key, *tokens]: - text = text.replace(secret, '[REDACTED]') - path.write_text(text) - (run / 'result.json').write_text(json.dumps(record, indent=2)) - (root / (config['engine'] + '-latest.json')).write_text(json.dumps({'run': str(run), 'passed': record['passed'], 'cleanup_errors': cleanup_errors})) - print(json.dumps(record, indent=2), flush=True) - sys.exit(0 if record['passed'] and not cleanup_errors else 1) diff --git a/services/agents-api/tests/official_environment_retrieve.py b/services/agents-api/tests/official_environment_retrieve.py index 557685e4b..83908f1d4 100644 --- a/services/agents-api/tests/official_environment_retrieve.py +++ b/services/agents-api/tests/official_environment_retrieve.py @@ -42,7 +42,7 @@ def client(key): result = {key: settings[key] for key in ("environment_id", "deleted_environment_id", "foreign_environment_id")} else: creation = {"agent": {"model": "test-model"}, "environment": { - "type": "self_hosted", "workspace_directory": "/private-workspace-" + str(uuid.uuid4())}} + "type": "self_hosted", "workspace_directory": "/workspace"}} session = api.beta.agents.sessions.create(**creation) removed = api.beta.agents.sessions.create(**creation) other = foreign.beta.agents.sessions.create(**creation) diff --git a/services/agents-api/tests/official_self_hosted.py b/services/agents-api/tests/official_self_hosted.py deleted file mode 100644 index ebe84b00e..000000000 --- a/services/agents-api/tests/official_self_hosted.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Public self-hosted execution against a built standalone service and real harness.""" - -import importlib.metadata -import json -import os -from pathlib import Path -import shlex -import sys -import threading -import time -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import OpenAI -from official_environment_retrieve import verify_environment -from official_self_hosted_creation import assert_creation_retry, create_initial_session - - -def main(): - settings = json.load(sys.stdin) - mode = settings.get("creation_mode", "empty_later") - assert mode in ("empty_later", "ordinary_initial", "streamed_initial") - initial_input = mode != "empty_later" - base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) - directory = Path(settings["evidence"]) - os.umask(0o077) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] - assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] - proof = {"scope": "public Session creation and input admission through a built standalone service; real remote execution", "creation_mode": mode, - "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, "environment_reads": {}} - observations = {"sdk": [], "raw": []} - ready = {name: threading.Event() for name in observations} - done = {name: threading.Event() for name in observations} - failures = [] - lock = threading.Lock() - session_id = None - expected_environment = None - - def write_private(name, value): - data = json.dumps(value, indent=2) - assert token not in data and foreign not in data, "caller credential in public evidence" - temporary = directory / (name + ".tmp") - temporary.write_text(data) - temporary.chmod(0o600) - temporary.replace(directory / (name + ".json")) - - def client(key=token): - return OpenAI(api_key=key, base_url=base + "/v1", max_retries=0, - _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=360)) - - def wait_for(predicate, timeout, label): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - with lock: - if failures: - raise AssertionError("public observer or input failed") from failures[0] - value = predicate() - if value: - return value - time.sleep(0.025) - raise AssertionError(label + " timed out") - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def prompt(phase): - text = "Run the exact command `./placement.sh " + phase + "` once with the native shell. " - text += "The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry. Report stdout, stderr and the verification memory briefly." - if phase == "first": - return text + " The fictional festival name to remember is " + settings["memory"] + "." - return text + " Recall the fictional festival name from the first Turn and read retained.txt." - - def check_session(value, status): - assert value["id"] == session_id and value["object"] == "agent.session" - assert value["environment"] == expected_environment - assert value["status"] == status and value["error"] is None - actions = [{"type": "environment_connection", "environment_id": expected_environment["id"]}] - assert value["required_actions"] == (actions if status == "requires_action" else []) - assert value["vault_ids"] == [] and isinstance(value["metadata"], dict) - assert isinstance(value["created_at"], int) and isinstance(value["last_active_at"], int) - - def observe(name): - completed = set() - - def accept(value): - with lock: - observations[name].append(value) - kind = value["type"] - assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") - if kind == "agent.session.requires_action": - check_session(value["session"], "requires_action") - if kind == "agent.session.turn.completed": - completed.add(value["turn"]["id"]) - return kind == "agent.session.idle" and len(completed) == 2 - - try: - if name == "sdk": - with client() as api: - with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: - ready[name].set() - for event in stream: - if accept(event.to_dict()): - return - else: - with httpx2.Client(trust_env=False, timeout=450) as raw: - with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" - ready[name].set() - for line in response.iter_lines(): - if line.startswith("data: ") and accept(json.loads(line[6:])): - return - raise AssertionError("live stream ended before both Turns") - except BaseException as error: - with lock: - failures.append(error) - finally: - done[name].set() - - try: - with client() as api, httpx2.Client( - base_url=base + "/v1", trust_env=False, timeout=360, - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1", "Host": "untrusted.example"}, - ) as raw: - sessions = api.beta.agents.sessions - instructions = "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0." - agent = {"model": "MiniMax-M3", "instructions": instructions, "tools": []} - environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} - creation = {"agent": agent, "environment": environment} - - def assert_empty(created): - value = created.to_dict() - assert value["status"] == "idle" and value["required_actions"] == [] and value["usage"] is None - assert value["created_at"] == value["last_active_at"] - assert value["environment"] == {**environment, "id": value["environment"]["id"], - "capability_directories": [], "remote_url": settings["remote_url"]} - assert list(sessions.turns.list(created.id)) == [] and list(sessions.items.list(created.id)) == [] - return value - - with sessions.create(**creation, input=None, stream=True) as stream: - created = next(stream) - assert created.type == "agent.session.created" - assert set(created.to_dict()) == {"type", "event_id", "session"} - proof["streamed_empty_creation"] = assert_empty(created.session) - for capability in (None, []): - value = sessions.create(agent=agent, environment={**environment, "capability_directories": capability}, input=None) - assert_empty(value) - creation_key = str(uuid.uuid4()) - if initial_input: - creation["input"] = prompt("first") if mode == "ordinary_initial" else message(prompt("first"))["input"] - created = create_initial_session(sessions, creation, creation_key, mode, assert_empty, proof) - initial = created.to_dict() - else: - created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) - initial = assert_empty(created) - session_id = created.id - expected_environment = {**environment, "id": initial["environment"]["id"], - "capability_directories": [], "remote_url": settings["remote_url"]} - agent_id = initial["agent"]["id"] - check_session(initial, "requires_action" if initial_input else "idle") - assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id - - def snapshot(name, status): - value = sessions.retrieve(session_id).to_dict() - check_session(value, status) - response = raw.get("/agents/sessions/" + session_id) - assert response.status_code == 200 and response.json() == value - assert [item.to_dict() for item in sessions.list(agent_id=agent_id, limit=1)] == [value] - proof["snapshots"][name] = value - return value - - def environment_snapshot(name, status=None): - environment_id = expected_environment["id"] - value = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, status) - response = raw.get("/agents/environments/" + environment_id) - assert response.status_code == 200 and response.headers["cache-control"] == "no-store" - wire = verify_environment(response.json(), environment_id, status) - proof["environment_reads"][name] = {"sdk": value, "raw": wire} - - snapshot("initial", "requires_action" if initial_input else "idle") - environment_snapshot("initial", "pending") - before = {value.id for value in sessions.list()} - for change in ({"input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.invalid/image.png"}]}]}, {"agent": {**agent, "tools": [ - {"type": "function", "name": "pending", "description": "Unsupported pending action", "parameters": {"type": "object"}}]}}, - {"environment": {"type": "self_hosted", "workspace_directory": "relative"}}): - reply = raw.post("/agents/sessions", json={**creation, **change}) - assert reply.status_code == 400 and reply.headers["content-type"].startswith("application/json") - assert {value.id for value in sessions.list()} == before - cancelled = raw.post("/agents/sessions/" + session_id + "/events", - json={"events": [{"type": "agent.session.input.cancel"}]}) - assert cancelled.status_code == (409 if initial_input else 204) - proof["pre_execution_cancel_status"] = cancelled.status_code - for events in ([message("Mixed input must not commit"), {"type": "agent.session.input.cancel"}], - [{"type": "agent.session.input.tool_result", "call_id": "unknown", "output": "unused"}]): - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": events}).status_code == 400 - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - for suffix in ("", "/events", "/turns", "/items"): - assert raw.get("/agents/sessions/" + session_id + suffix, - headers={"Authorization": "Bearer " + foreign}).status_code == 404 - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Foreign")]}, - headers={"Authorization": "Bearer " + foreign}).status_code == 404 - assert raw.get("/agents/sessions", headers={"Authorization": "Bearer " + foreign}).json()["data"] == [] - proof["unsupported_profile_rejected_before_writes"] = True - proof["tenant_isolation"] = True - - for name in observations: - threading.Thread(target=observe, args=(name,), daemon=True).start() - wait_for(lambda: all(value.is_set() for value in ready.values()), 25, "live subscriptions") - first_event, first_key = message(prompt("first")), str(uuid.uuid4()) - submitted = threading.Event() - submission = {} - - def submit_first(): - try: - with client() as submitting: - began = time.monotonic() - response = submitting.beta.agents.sessions.events.with_raw_response.create( - session_id, events=[first_event], idempotency_key=first_key) - assert response.status_code == 204 and response.parse() is None - submission["elapsed_seconds"] = time.monotonic() - began - submission["status"] = response.status_code - assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable Turn admission" - except BaseException as error: - with lock: - failures.append(error) - finally: - submitted.set() - - if not initial_input: - threading.Thread(target=submit_first, daemon=True).start() - wait_for(lambda: all(any(item["type"] == "agent.session.requires_action" for item in values) - for values in observations.values()), 25, "offline action") - pending = snapshot("waiting", "requires_action") - assert pending["usage"] is None - if mode == "streamed_initial": - assert proof["creation_events"][1]["session"] == pending - began = time.monotonic() - # Exceed the server's ordinary write timeout while no executor or daemon exists. - while time.monotonic() - began < 32: - if not initial_input: - assert not submitted.is_set(), "input returned before executor/native readiness" - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - time.sleep(0.25) - proof["offline_observation_seconds"] = time.monotonic() - began - with lock: - for values in observations.values(): - if initial_input: - assert values == [], "GET events replayed initial creation activity" - else: - assert len(values) == 1 and values[0]["type"] == "agent.session.requires_action" - assert values[0]["session"] == pending - write_private("waiting", {"session_id": session_id, "environment_id": expected_environment["id"], - "remote_url": expected_environment["remote_url"], "creation_mode": mode}) - wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection before daemon startup") - environment_snapshot("connected_before_execution", "connected") - write_private("initial-connection-read", {"environment_id": expected_environment["id"]}) - if initial_input: - proof["first_input"] = {"source": mode, "creation_response_elapsed_seconds": proof["creation_response_elapsed_seconds"]} - else: - wait_for(submitted.is_set, 150, "first input admission") - assert submission["elapsed_seconds"] >= 32 - proof["first_input"] = submission - - def completed(count): - turns = list(sessions.turns.list(session_id, order="asc")) - assert len(turns) <= count - assert not any(turn.status in ("failed", "cancelled") for turn in turns) - return turns if len(turns) == count and all(turn.status == "completed" for turn in turns) else None - - first_turn = wait_for(lambda: completed(1), 150, "first real Turn")[0] - snapshot("after_first", "idle") - environment_snapshot("after_first") - if initial_input: - assert_creation_retry(sessions, raw, creation, creation_key, session_id) - else: - sessions.events.create(session_id, events=[first_event], idempotency_key=first_key) - assert len(list(sessions.turns.list(session_id))) == 1 - write_private("first-completed", {"turn_id": first_turn.id}) - wait_for(lambda: (directory / "resume-ready.json").exists(), 45, "retained native binding and executor reconnection") - environment_snapshot("before_resumed", "connected") - second_event, second_key = message(prompt("resumed")), str(uuid.uuid4()) - reply = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [second_event]}, - headers={"Idempotency-Key": second_key}) - assert reply.status_code == 204 and reply.content == b"" - turns = wait_for(lambda: completed(2), 150, "second real Turn") - wait_for(lambda: all(value.is_set() for value in done.values()), 30, "both completion streams") - final = snapshot("final", "idle") - environment_snapshot("after_remote_file_and_history") - items = list(sessions.items.list(session_id, limit=100, order="asc")) - proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] - for turn, phase in zip(turns, ("first", "resumed")): - assert sessions.turns.retrieve(turn.id, session_id=session_id) == turn - group = [item.to_dict() for item in items if item.turn_id == turn.id] - commands = [item for item in group if item["type"] == "command_execution" - and "remote-stdout:" + phase in item.get("output", "") - and "remote-stderr:" + phase in item.get("output", "")] - assert len(commands) == 1 - command = commands[0] - assert command["exit_code"] == 7 and command["cwd"] == settings["workspace_directory"] - argv = shlex.split(command["command"]) - assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./placement.sh " + phase] - answer = "\n".join(part.get("text", "") for item in group - if item["type"] == "message" and item.get("role") == "assistant" for part in item["content"]) - assert settings["memory"] in answer and settings["instruction"] in answer - assert "WRONG_LOCAL_INSTRUCTIONS" not in answer - assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] - assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] - retries = [(second_event, second_key)] if initial_input else [(first_event, first_key), (second_event, second_key)] - for event, key in retries: - sessions.events.create(session_id, events=[event], idempotency_key=key) - response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed retry")]}, - headers={"Idempotency-Key": key}) - assert response.status_code == 409 - if initial_input: - assert_creation_retry(sessions, raw, creation, creation_key, session_id) - time.sleep(1) - assert list(sessions.turns.list(session_id, order="asc")) == turns - assert list(sessions.items.list(session_id, limit=100, order="asc")) == items - proof["retries_preserved_turns_and_items"] = True - - for values in observations.values(): - ids = [value["event_id"] for value in values] - assert len(ids) == len(set(ids)) - kinds = [value["type"] for value in values] - connected = kinds.index("agent.session.environment.connected") - cleared, admitted = kinds.index("agent.session.idle"), kinds.index("agent.session.turn.created") - assert connected < cleared < admitted - if initial_input: - assert "agent.session.created" not in kinds and "agent.session.requires_action" not in kinds - else: - request = kinds.index("agent.session.requires_action") - assert request < connected and kinds.count("agent.session.requires_action") == 1 - assert values[request]["session"] == pending - assert set(values[cleared]) == {"type", "event_id", "session"} - check_session(values[cleared]["session"], "idle") - assert values[cleared]["session"]["usage"] is None - turn_ids = [turn.id for turn in turns] - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == turn_ids - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == turn_ids - for event in values: - if event["type"].startswith("agent.session.environment."): - assert set(event) == {"type", "event_id", "session_id", "environment"} - assert event["environment"]["id"] == expected_environment["id"] and event["environment"]["error"] is None - assert [value["event_id"] for value in observations["sdk"]] == [value["event_id"] for value in observations["raw"]] - with client() as recovered: - resource = recovered.beta.agents.sessions - assert resource.retrieve(session_id).to_dict() == final - assert list(resource.turns.list(session_id, order="asc")) == turns - assert list(resource.items.list(session_id, limit=100, order="asc")) == items - verify_environment(recovered.beta.agents.environments.retrieve(expected_environment["id"]).to_dict(), expected_environment["id"]) - proof["query_recovery"] = True - proof["live_event_scope"] = "common GET subscription interval; creation events are recorded separately" - proof["status"] = "public_creation_waiting_admission_and_real_remote_continuation_verified" - print("Built service (" + mode + "): public self-hosted creation/wait, safe Environment GET, fixed SDK/raw SSE, two real remote Turns and retry recovery passed.", flush=True) - finally: - with lock: - proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) - write_private("public-environment-proof", proof) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/agents-api/tests/official_self_hosted_cancel.py index 03d220f80..94f57416e 100644 --- a/services/agents-api/tests/official_self_hosted_cancel.py +++ b/services/agents-api/tests/official_self_hosted_cancel.py @@ -72,7 +72,7 @@ def current(session_id): phase = settings["phase"] if phase == "create": request = {"agent": {"model": "test-model", "instructions": "Controlled cancellation admission."}, - "environment": {"type": "self_hosted", "workspace_directory": "/private-cancel-workspace"}} + "environment": {"type": "self_hosted", "workspace_directory": "/workspace"}} main_session = sessions.create(**request) initial = sessions.create(**request, input="Keep this pending initial input.") later = sessions.create(**request) diff --git a/services/agents-api/tests/official_self_hosted_cancel_native.py b/services/agents-api/tests/official_self_hosted_cancel_native.py deleted file mode 100644 index 2c4a8267a..000000000 --- a/services/agents-api/tests/official_self_hosted_cancel_native.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Real public self-hosted cancellation and cold continuation against a built service.""" - -import importlib.metadata -import json -import os -from pathlib import Path -import shlex -import sys -import threading -import time -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import OpenAI -from official_environment_retrieve import verify_environment - - -def main(): - settings = json.load(sys.stdin) - base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) - directory = Path(settings["evidence"]) - os.umask(0o077) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] - proof = {"scope": "built service; public self_hosted long-command cancellation and real cold continuation", - "case": "public_cancellation", "sdk_version": distribution.version, "sdk_commit": pin["commit"], - "snapshots": {}, "limits": ["204 is durable admission, not process exit", - "OS observations are recorded separately by the native fixture; no general quiescence guarantee", - "Cancellation preserves observed output and usage; complete native final usage remains unverified"]} - observations = {"sdk": [], "raw": []} - ready = {name: threading.Event() for name in observations} - done = {name: threading.Event() for name in observations} - failures, lock = [], threading.Lock() - session_id = None - - def write_private(name, value): - data = json.dumps(value, indent=2) - assert token not in data and foreign not in data, "caller credential in public evidence" - temporary = directory / (name + ".tmp") - temporary.write_text(data) - temporary.chmod(0o600) - temporary.replace(directory / (name + ".json")) - - def client(): - return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=360)) - - def wait_for(predicate, timeout, label): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - with lock: - if failures: - raise AssertionError("public observer or input failed") from failures[0] - value = predicate() - if value: - return value - time.sleep(0.025) - raise AssertionError(label + " timed out") - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def observe(name): - terminal = {} - - def accept(value): - with lock: - observations[name].append(value) - kind = value["type"] - assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed") - if kind in ("agent.session.turn.cancelled", "agent.session.turn.completed"): - terminal[value["turn"]["id"]] = kind - return kind == "agent.session.idle" and len(terminal) == 2 - - try: - if name == "sdk": - with client() as api: - with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: - ready[name].set() - for event in stream: - if accept(event.to_dict()): - return - else: - with httpx2.Client(trust_env=False, timeout=450) as raw: - with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" - ready[name].set() - for line in response.iter_lines(): - if line.startswith("data: ") and accept(json.loads(line[6:])): - return - raise AssertionError("live stream ended before cancelled and resumed Turns") - except BaseException as error: - with lock: - failures.append(error) - finally: - done[name].set() - - try: - with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: - sessions = api.beta.agents.sessions - environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} - instructions = "Use the native shell for exact requested commands. Do not add wrappers, separators or recovery. Wait or poll a running command; never finish the Turn while it is still running." - creation = {"agent": {"model": settings.get("model") or "MiniMax-M3", "instructions": instructions, "tools": []}, "environment": environment} - creation_key = str(uuid.uuid4()) - created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) - session_id, environment_id = created.id, created.environment.id - expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} - assert created.environment.to_dict() == expected_environment - assert created.status == "idle" and created.required_actions == [] and created.usage is None - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") - - def snapshot(name, status): - value = sessions.retrieve(session_id).to_dict() - assert value["id"] == session_id and value["environment"] == expected_environment and value["status"] == status - assert value["error"] is None - response = raw.get("/agents/sessions/" + session_id) - assert response.status_code == 200 and response.json() == value - proof["snapshots"][name] = value - return value - - for name in observations: - threading.Thread(target=observe, args=(name,), daemon=True).start() - wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") - first_command = "./placement.sh first" - first_event = message("First run the exact command `" + first_command + "` once with the native shell. Its exit 7 is intentional; preserve stdout/stderr and do not retry. Then run the exact command `./long.sh cancel` once as a separate native shell call. Keep waiting or polling, and do not finish while that long command is running. Remember the fictional festival name " + settings["memory"] + ".") - first_key, cancel_key = str(uuid.uuid4()), str(uuid.uuid4()) - cancel_events = [{"type": "agent.session.input.cancel"}] - submitted = threading.Event() - - def submit_first(): - try: - with client() as submitting: - response = submitting.beta.agents.sessions.events.with_raw_response.create(session_id, events=[first_event], idempotency_key=first_key) - assert response.status_code == 204 and response.parse() is None - assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable admission" - except BaseException as error: - with lock: - failures.append(error) - finally: - submitted.set() - - threading.Thread(target=submit_first, daemon=True).start() - wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "pending public input") - pending = snapshot("pending", "requires_action") - assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] - assert not submitted.is_set() and list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - write_private("waiting", {"session_id": session_id, "environment_id": environment_id, - "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) - wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") - write_private("initial-connection-read", {"environment_id": environment_id}) - wait_for(submitted.is_set, 150, "first input admission") - first_turn = list(sessions.turns.list(session_id))[0] - - def command_items(turn_id, phase): - return [item.to_dict() for item in sessions.items.list(session_id, limit=100, order="asc") - if item.turn_id == turn_id and item.type == "command_execution" - and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] - - partial = wait_for(lambda: command_items(first_turn.id, "first"), 120, "public partial command output") - assert len(partial) == 1 - assert partial[0]["cwd"] == settings["workspace_directory"] and partial[0]["exit_code"] == 7 - argv = shlex.split(partial[0]["command"]) - assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", first_command] - wait_for(lambda: (directory / "cancel-ready.json").exists(), 30, "independent long-command activity") - def running_command(): - return [item.to_dict() for item in sessions.items.list(session_id, limit=100, order="asc") - if item.turn_id == first_turn.id and item.type == "command_execution" - and shlex.split(item.command)[1:] == ["-lc", "./long.sh cancel"]] - running = wait_for(running_command, 15, "public long-command identity") - assert len(running) == 1 and running[0]["status"] == "in_progress" - assert running[0]["cwd"] == settings["workspace_directory"] - proof["running_command_before_cancel"] = running[0] - assert sessions.turns.retrieve(first_turn.id, session_id=session_id).status == "in_progress" - proof["partial_before_cancel"] = partial - proof["partial_output_scope"] = "Completed first command within the still-active Turn; running-command output deltas are not asserted" - write_private("cancel-requested", {"turn_id": first_turn.id, "request_started_unix": str(time.time())}) - began = time.monotonic() - response = sessions.events.with_raw_response.create(session_id, events=cancel_events, idempotency_key=cancel_key) - assert response.status_code == 204 and response.parse() is None - proof["cancel_response"] = {"status": 204, "elapsed_seconds": time.monotonic() - began} - first_turn = wait_for(lambda: (turn if (turn := sessions.turns.retrieve(first_turn.id, session_id=session_id)).status == "cancelled" else None), 45, "cancelled public Turn") - cancelled_output = command_items(first_turn.id, "first") - assert len(cancelled_output) == 1 and cancelled_output[0]["id"] == partial[0]["id"] - assert partial[0]["output"] in cancelled_output[0]["output"] - proof["retained_cancelled_output"] = cancelled_output - snapshot("cancelled", "idle") - write_private("first-cancelled", {"turn_id": first_turn.id}) - wait_for(lambda: (directory / "resume-ready.json").exists(), 90, "native exit measurement and retained binding") - second_event = message("Run the exact command `./resume.sh` once with the native shell. It waits for the test gate, then executes the resumed verification with intentional exit 7; preserve that exit and do not retry. Keep waiting while it runs. Read retained.txt and recall the fictional festival name from the first Turn. Include the remembered name and command stdout/stderr in the final answer.") - second_key = str(uuid.uuid4()) - response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [second_event]}, headers={"Idempotency-Key": second_key}) - assert response.status_code == 204 and response.content == b"" - wait_for(lambda: (directory / "resumed-active.json").exists(), 150, "actual resumed command activity") - turns = list(sessions.turns.list(session_id, order="asc")) - assert len(turns) == 2 and turns[0].status == "cancelled" and turns[1].status == "in_progress" - second_id = turns[1].id - sessions.events.create(session_id, events=cancel_events, idempotency_key=cancel_key) - response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": cancel_events}, headers={"Idempotency-Key": cancel_key}) - assert response.status_code == 204 and response.content == b"" - time.sleep(1) - assert sessions.turns.retrieve(second_id, session_id=session_id).status == "in_progress" - write_private("old-cancel-retried", {"turn_id": second_id}) - second_turn = wait_for(lambda: (turn if (turn := sessions.turns.retrieve(second_id, session_id=session_id)).status == "completed" else None), 150, "uncancelled resumed Turn") - wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") - final = snapshot("final", "idle") - turns = [first_turn, second_turn] - items = list(sessions.items.list(session_id, limit=100, order="asc")) - resumed = command_items(second_id, "resumed") - assert len(resumed) == 1 and resumed[0]["exit_code"] == 7 and resumed[0]["cwd"] == settings["workspace_directory"] - argv = shlex.split(resumed[0]["command"]) - assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./resume.sh"] - answer = "\n".join(part.get("text", "") for item in items if item.turn_id == second_id and item.type == "message" - and item.role == "assistant" for part in item.to_dict()["content"]) - assert settings["memory"] in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer - proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] - assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] - assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] - for event, key in ((first_event, first_key), (second_event, second_key)): - sessions.events.create(session_id, events=[event], idempotency_key=key) - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed cancel key")]}, headers={"Idempotency-Key": cancel_key}).status_code == 409 - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": cancel_events}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 - time.sleep(1) - assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, limit=100, order="asc")) == items - for values in observations.values(): - ids = [value["event_id"] for value in values] - assert len(ids) == len(set(ids)) - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.cancelled"] == [first_turn.id] - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == [second_id] - assert observations["sdk"] == observations["raw"], "SDK/raw common live event payloads differ" - with client() as recovered: - resource = recovered.beta.agents.sessions - assert resource.retrieve(session_id).to_dict() == final - assert list(resource.turns.list(session_id, order="asc")) == turns - assert list(resource.items.list(session_id, limit=100, order="asc")) == items - proof["query_recovery"] = proof["old_cancel_did_not_retarget"] = proof["retries_preserved_turns_and_items"] = True - proof["status"] = "public_cancelled_turn_output_and_cold_continuation_verified" - print("Built service: real public self-hosted cancellation, retained output/history, cold continuation and old-cancel retry passed.", flush=True) - finally: - with lock: - proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) - write_private("public-cancellation-proof", proof) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_self_hosted_creation.py b/services/agents-api/tests/official_self_hosted_creation.py deleted file mode 100644 index 2d132261a..000000000 --- a/services/agents-api/tests/official_self_hosted_creation.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Initial creation checks shared by the public self-hosted native fixture.""" - -import time - - -def create_initial_session(sessions, request, key, mode, assert_empty, proof): - began = time.monotonic() - arguments = {**request, "extra_headers": {"Idempotency-Key": key}, "timeout": 10} - if mode == "ordinary_initial": - created = sessions.create(**arguments) - proof["creation_response"] = created.to_dict() - elapsed = time.monotonic() - began - else: - assert mode == "streamed_initial" - with sessions.create(**arguments, stream=True) as stream: - first = next(stream) - elapsed = time.monotonic() - began - assert first.type == "agent.session.created" - assert set(first.to_dict()) == {"type", "event_id", "session"} - assert_empty(first.session) - action = next(stream) - assert action.type == "agent.session.requires_action" - assert set(action.to_dict()) == {"type", "event_id", "session"} - assert action.event_id != first.event_id - assert action.session.id == first.session.id - assert action.session.environment == first.session.environment - proof["creation_events"] = [first.to_dict(), action.to_dict()] - created = action.session - proof["creation_stream_closed_before_connection"] = True - assert elapsed < 10, "initial creation waited for an offline executor" - assert created.status == "requires_action" and created.error is None and created.usage is None - proof["creation_response_elapsed_seconds"] = elapsed - return created - - -def assert_creation_retry(sessions, raw, request, key, session_id): - assert sessions.create(**request, extra_headers={"Idempotency-Key": key}).id == session_id - response = raw.post("/agents/sessions", json={**request, "input": "Changed initial retry"}, - headers={"Idempotency-Key": key}) - assert response.status_code == 409 diff --git a/services/agents-api/tests/official_self_hosted_functions.py b/services/agents-api/tests/official_self_hosted_functions.py deleted file mode 100644 index d21b1a2f8..000000000 --- a/services/agents-api/tests/official_self_hosted_functions.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Public function admission and reads using controlled calls, observations and receipts.""" - -from concurrent.futures import ThreadPoolExecutor -import importlib.metadata -import json -from pathlib import Path -import sys -import threading -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import APIStatusError, OpenAI - - -def main(): - settings = json.load(sys.stdin) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] - base, token = settings["base"], settings["token"] - headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} - - def client(): - return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) - - with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: - sessions = api.beta.agents.sessions - endpoint = base + "/v1/agents/sessions" - - def submit(session_id, events, key, expected=204, sdk=False, key_token=token): - if sdk: - with client() as caller: - try: - response = caller.beta.agents.sessions.events.with_raw_response.create( - session_id, events=events, idempotency_key=key) - assert response.status_code == expected == 204 and response.content == b"" - assert response.parse() is None - except APIStatusError as error: - assert error.status_code == expected and expected != 204 - else: - response = raw.post(endpoint + "/" + session_id + "/events", json={"events": events}, - headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) - assert response.status_code == expected, (response.status_code, expected) - if expected == 204: - assert response.content == b"" - - def current(session_id): - value = sessions.retrieve(session_id).to_dict() - response = raw.get(endpoint + "/" + session_id) - assert response.status_code == 200 and response.json() == value - assert value["environment"]["type"] == "self_hosted" - return value - - phase = settings["phase"] - if phase == "create": - tool = {"type": "function", "name": "lookup_ticket", "description": "Look up a ticket.", - "parameters": {"type": "object", "properties": {"ticket": {"type": "string"}}, - "required": ["ticket"], "additionalProperties": False}} - expected = {**tool, "defer_loading": False} - agent = {"model": "test-model", "tools": [tool]} - environment = {"type": "self_hosted", "workspace_directory": "/private-function-workspace"} - key = str(uuid.uuid4()) - session = sessions.create(agent=agent, environment=environment, extra_headers={"Idempotency-Key": key}) - assert session.agent.tools[0].to_dict() == expected - assert sessions.create(agent={**agent, "tools": [expected]}, environment=environment, - extra_headers={"Idempotency-Key": key}).id == session.id - saved = api.beta.agents.create(**agent) - response = raw.post(endpoint, json={"agent_id": saved.id, "environment": environment}) - assert response.status_code == 200 - saved_session = response.json() - assert saved_session["agent"]["id"] == saved.id and saved_session["agent"]["tools"] == [expected] - api.beta.agents.update(saved.id, tools=[{**tool, "description": "Changed later."}]) - assert current(saved_session["id"]) == saved_session - initial = sessions.create(agent=agent, environment=environment, input="Retain the initial function prompt.") - later = sessions.create(agent=agent, environment=environment) - for tools in ([{**tool, "defer_loading": True}], [{**tool, "parameters": None}], [tool, tool]): - response = raw.post(endpoint, json={"agent": {**agent, "tools": tools}, "environment": environment}) - assert response.status_code == 400 - unsupported = api.beta.agents.create(**{**agent, "tools": [{**tool, "defer_loading": True}]}) - response = raw.post(endpoint, json={"agent_id": unsupported.id, "environment": environment}) - assert response.status_code == 400 - result = {"id": session.id, "saved_id": saved_session["id"], "initial_id": initial.id, "later_id": later.id, - "environment_id": session.environment.id, "tools": [expected], "result_key": str(uuid.uuid4())} - unknown_result = {"type": "agent.session.input.tool_result", "turn_id": str(uuid.uuid4()), - "call_id": "unknown", "success": True} - for sdk in (True, False): - submit(session.id, [unknown_result], "idle-result", 404, sdk) - assert current(session.id)["status"] == "idle" - for session_id in (session.id, saved_session["id"], initial.id, later.id): - assert list(sessions.turns.list(session_id)) == list(sessions.items.list(session_id)) == [] - else: - result = settings["accepted"] - session_id, turn = result["id"], settings["turn_id"] - calls = settings["calls"] - outputs = [{"success": False, "error": "controlled tool failure", "output": [ - {"type": "input_text", "text": "before"}, {"type": "input_text", "text": ""}, - {"type": "input_image", "image_url": "data:image/png;base64,AA=="}, - {"type": "input_text", "text": "after"}]}, {"success": True, "output": None, "error": None}, {"success": True}] - batch = [{"type": "agent.session.input.tool_result", "turn_id": turn, "call_id": call, **output} - for call, output in zip(calls, outputs)] - before = current(session_id) - assert before["agent"]["tools"] == result["tools"] - if phase in ("reject", "submit"): - assert before["status"] == "requires_action" - actions = before["required_actions"] - assert {action["call_id"] for action in actions} == set(calls) - assert all(action["type"] == "function_call" and action["turn_id"] == turn and - action["name"] == "lookup_ticket" and action["arguments"] == {"ticket": "42"} for action in actions) - if phase == "reject": - missing = {**batch[1], "call_id": "missing"} - foreign_turn = {**batch[1], "turn_id": settings["other_turn"], "call_id": settings["other_call"]} - cancel = {"type": "agent.session.input.cancel"} - message = {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": "Must roll back."}]}]} - for events, status in (([batch[0], missing], 404), ([batch[0], foreign_turn], 404), - ([batch[0], {**batch[0], "success": True}], 409), - ([batch[0], cancel], 400), ([message, batch[0]], 400)): - for sdk in (True, False): - submit(session_id, events, "failed-batch", status, sdk) - submit(result["saved_id"], batch, "other-session", 404) - submit(session_id, batch, "foreign", 404, key_token=settings["foreign_token"]) - for pending_id in (result["initial_id"], result["later_id"]): - waiting = current(pending_id) - for sdk in (True, False): - submit(pending_id, batch, "pending-result", 409, sdk) - assert current(pending_id) == waiting and waiting["status"] == "requires_action" - assert current(session_id) == before - elif phase == "submit": - barrier = threading.Barrier(4) - - def submit_once(index): - barrier.wait(timeout=10) - submit(session_id, batch, result["result_key"], sdk=bool(index % 2)) - - with ThreadPoolExecutor(max_workers=4) as workers: - list(workers.map(submit_once, range(4))) - assert current(session_id) == before - assert not any(item.type == "function_call_output" for item in sessions.items.list(session_id)) - result = {**result, "batch": batch} - else: - assert sessions.turns.retrieve(turn, session_id=session_id).status == "completed" - assert before["status"] == ("idle" if phase == "terminal" else "requires_action") - for sdk in (True, False): - submit(session_id, batch, result["result_key"], sdk=sdk) - submit(session_id, list(reversed(batch)), result["result_key"], 409, sdk) - submit(session_id, [{**batch[1], "output": "changed"}], "changed-result", 409, sdk) - assert current(session_id) == before - if phase == "later": - assert len(before["required_actions"]) == 1 - assert before["required_actions"][0]["turn_id"] == settings["next_turn"] - assert before["required_actions"][0]["call_id"] == settings["next_call"] - listed = [item.to_dict() for item in sessions.items.list(session_id, order="asc")] - response = raw.get(endpoint + "/" + session_id + "/items", params={"order": "asc"}) - assert response.status_code == 200 and response.json()["data"] == listed - projected = [item for item in listed if item["type"] == "function_call_output"] - assert [item["call_id"] for item in projected] == calls - for item, output in zip(projected, outputs): - for field in ("output", "error"): - assert (field in item) == (field in output) and item.get(field) == output.get(field) - environment = api.beta.agents.environments.retrieve(result["environment_id"]).to_dict() - assert environment["type"] == "self_hosted" and environment["files"] == environment["plugins"] == environment["skills"] == [] - print(json.dumps(result)) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_self_hosted_functions_native.py b/services/agents-api/tests/official_self_hosted_functions_native.py deleted file mode 100644 index 6e7051b47..000000000 --- a/services/agents-api/tests/official_self_hosted_functions_native.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Real public self-hosted functions through the pinned SDK's automatic handlers.""" - -import importlib.metadata -import json -import os -from pathlib import Path -import shlex -import sys -import threading -import time -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import OpenAI -from official_environment_retrieve import verify_environment - - -def main(): - settings = json.load(sys.stdin) - base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) - directory = Path(settings["evidence"]) - os.umask(0o077) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] - memory = "festival-" + uuid.uuid4().hex - private_exception = "private-handler-exception-" + uuid.uuid4().hex - proof = {"case": "public_functions", "scope": "built service, real remote MiniMax, official automatic function handlers", - "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, - "limits": ["One function success and one SDK-mapped error; no image or complete tool-set claim", - "Cold continuation is tested; recovery of an interrupted native call is not"]} - observations = {"sdk": [], "raw": []} - ready = {name: threading.Event() for name in observations} - done = {name: threading.Event() for name in observations} - failures, submissions, input_responses, handler_calls, helper_events = [], [], [], [], [[], []] - lock = threading.Lock() - session_id = None - - def write_private(name, value): - data = json.dumps(value, indent=2) - assert token not in data and foreign not in data and private_exception not in data - temporary = directory / (name + ".tmp") - temporary.write_text(data) - temporary.chmod(0o600) - temporary.replace(directory / (name + ".json")) - - def capture_result(response): - request = response.request - if request.method == "POST" and request.url.path.endswith("/events"): - events = json.loads(request.content).get("events", []) - if events and all(event["type"] == "agent.session.input.tool_result" for event in events): - assert len(events) == 1 - with lock: - submissions.append({"status": response.status_code, "key": request.headers["Idempotency-Key"], "event": events[0]}) - elif events and all(event["type"] == "agent.session.input.message" for event in events): - with lock: - input_responses.append({"status": response.status_code, "key": request.headers["Idempotency-Key"]}) - - def client(capture=False): - return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=360, - event_hooks={"response": [capture_result]} if capture else None)) - - def wait_for(predicate, timeout, label): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - with lock: - if failures: - raise AssertionError("public function observer or handler failed") from failures[0] - value = predicate() - if value: - return value - time.sleep(0.025) - raise AssertionError(label + " timed out") - - def observe(name): - completed = set() - - def accept(value): - with lock: - observations[name].append(value) - kind = value["type"] - assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") - if kind == "agent.session.turn.completed": - completed.add(value["turn"]["id"]) - return kind == "agent.session.idle" and len(completed) == 2 - - try: - if name == "sdk": - with client() as api: - with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: - ready[name].set() - for event in stream: - if accept(event.to_dict()): - return - else: - with httpx2.Client(trust_env=False, timeout=450) as raw: - with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" - ready[name].set() - for line in response.iter_lines(): - if line.startswith("data: ") and accept(json.loads(line[6:])): - return - raise AssertionError("live stream ended before two function Turns") - except BaseException as error: - with lock: - failures.append(error) - finally: - done[name].set() - - try: - with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: - sessions = api.beta.agents.sessions - tool = {"type": "function", "name": "lookup_festival", "description": "Call once per requested phase to obtain the festival record.", - "parameters": {"type": "object", "properties": {"phase": {"type": "string", "enum": ["first", "resumed"]}}, - "required": ["phase"], "additionalProperties": False}} - environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} - instructions = "Use lookup_festival exactly once when requested, with the requested phase. Use the native shell for exact requested commands. The command argument must match the supplied text: no wrapper, appended echo, separators or error recovery. Exit 7 is intentional and must remain the native exit status. A function-tool failure is intentional: report it without retries or alternate tools." - creation = {"agent": {"model": "MiniMax-M3", "instructions": instructions, "tools": [tool]}, "environment": environment} - creation_key = str(uuid.uuid4()) - created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) - session_id, environment_id = created.id, created.environment.id - expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} - assert created.environment.to_dict() == expected_environment and created.agent.tools[0].to_dict() == {**tool, "defer_loading": False} - assert created.status == "idle" and created.required_actions == [] and created.usage is None - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") - - def snapshot(name, status): - value = sessions.retrieve(session_id).to_dict() - assert value["environment"] == expected_environment and value["status"] == status and value["error"] is None - response = raw.get("/agents/sessions/" + session_id) - assert response.status_code == 200 and response.json() == value - proof["snapshots"][name] = value - return value - - def accepted_result(index): - with lock: - successful = [entry for entry in submissions if entry["status"] == 204] - assert len(successful) == index + 1 - assert successful[index]["key"] and successful[index]["event"]["success"] == (index == 0) - return successful[index] - - def replay_result(submission): - response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [submission["event"]]}, - headers={"Idempotency-Key": submission["key"]}) - assert response.status_code == 204 and response.content == b"" - - def run_turn(index): - phase = ("first", "resumed")[index] - prompt = ("Call lookup_festival once with phase first. Remember its festival value without writing it to files. Then run the exact native shell command `./placement.sh first` once. Its exit 7 is intentional; preserve stdout/stderr and do not retry. Include the festival value and command output in your final answer." - if index == 0 else "Recall the festival value returned by lookup_festival in the first Turn. Call lookup_festival once with phase resumed; its failure is intentional, do not retry it. Read retained.txt in a separate native tool call. Then run the exact native shell command `./placement.sh resumed` once in a new tool call with intentional exit 7. Do not combine the file read with this command. Include the remembered festival, retained file contents, exact tool error and command stdout/stderr in your final answer.") - assert memory not in prompt - with client(capture=True) as submitting: - resource = submitting.beta.agents.sessions - - def lookup_festival(arguments): - try: - assert arguments == {"phase": phase} - handler_calls.append({"phase": phase, "arguments": arguments}) - assert len(handler_calls) == index + 1, "function handler executed more than once" - call = [event["item"] for event in helper_events[index] if event["type"] == "agent.session.turn.item.added" - and event["item"]["type"] == "function_call"][-1] - - def pending_action(): - current = resource.retrieve(session_id) - return current.to_dict() if current.status == "requires_action" and any( - action.type == "function_call" and action.call_id == call["call_id"] and action.turn_id == call["turn_id"] - for action in current.required_actions) else None - - pending = wait_for(pending_action, 15, "persisted function action") - proof["snapshots"][phase + "_function_pending"] = pending - if index == 1: - before = [item.to_dict() for item in resource.items.list(session_id, order="asc", limit=100)] - replay_result(accepted_result(0)) - assert pending_action() == pending, "old result changed the current pending action" - assert [item.to_dict() for item in resource.items.list(session_id, order="asc", limit=100)] == before - assert len(list(resource.turns.list(session_id))) == 2 - proof["old_result_did_not_retarget"] = True - except BaseException as error: - with lock: - failures.append(error) - raise - if index == 1: - raise RuntimeError(private_exception) - return {"festival": memory} - - with resource.stream(session_id, input=prompt, tool_handlers={"lookup_festival": lookup_festival}, - idempotency_key=input_keys[index], timeout=360) as stream: - for event in stream: - helper_events[index].append(event.to_dict()) - - for name in observations: - threading.Thread(target=observe, args=(name,), daemon=True).start() - wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") - input_keys = [str(uuid.uuid4()), str(uuid.uuid4())] - first_done = threading.Event() - - def first_turn(): - try: - run_turn(0) - except BaseException as error: - with lock: - failures.append(error) - finally: - first_done.set() - - threading.Thread(target=first_turn, daemon=True).start() - wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "offline input reservation") - pending = snapshot("offline", "requires_action") - assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] - assert not first_done.is_set() and not handler_calls and not input_responses - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - write_private("waiting", {"session_id": session_id, "environment_id": environment_id, - "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) - wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") - write_private("initial-connection-read", {"environment_id": environment_id}) - wait_for(first_done.is_set, 180, "first real function Turn") - first_result = accepted_result(0) - first_id = first_result["event"]["turn_id"] - assert sessions.turns.retrieve(first_id, session_id=session_id).status == "completed" - snapshot("first_completed", "idle") - first_items = list(sessions.items.list(session_id, order="asc", limit=100)) - replay_result(first_result) - assert list(sessions.items.list(session_id, order="asc", limit=100)) == first_items - write_private("first-completed", {"turn_id": first_id, "call_id": first_result["event"]["call_id"]}) - wait_for(lambda: (directory / "resume-ready.json").exists(), 40, "retained native binding") - run_turn(1) - wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") - second_result = accepted_result(1) - proof["accepted_results"] = [first_result, second_result] - assert input_responses == [{"status": 204, "key": key} for key in input_keys] - assert first_result["key"] != second_result["key"] and not ({first_result["key"], second_result["key"]} & set(input_keys)) - assert first_result["event"]["output"] == json.dumps({"festival": memory}, separators=(",", ":")) - assert "error" not in first_result["event"] and second_result["event"]["error"] == "Tool handler failed." - assert "output" not in second_result["event"] - final = snapshot("final", "idle") - assert final["required_actions"] == [] and proof["old_result_did_not_retarget"] - # Native release precedes Turn completion; executor reconnection is asynchronous. - connected = wait_for(lambda: value if (value := api.beta.agents.environments.retrieve(environment_id, timeout=5)).status == "connected" else None, - 30, "executor reconnection after completion") - proof["final_environment"] = verify_environment(connected.to_dict(), environment_id, "connected") - turns = list(sessions.turns.list(session_id, order="asc")) - items = list(sessions.items.list(session_id, order="asc", limit=100)) - assert len(turns) == 2 and all(turn.status == "completed" for turn in turns) - for index, phase in enumerate(("first", "resumed")): - turn_id = turns[index].id - events = helper_events[index] - added = [event for event in events if event["type"] == "agent.session.turn.item.added"] - calls = [event for event in added if event["item"]["type"] == "function_call"] - results = [event for event in added if event["item"]["type"] == "function_call_output"] - completed = [event for event in events if event["type"] == "agent.session.turn.completed"] - assert len(calls) == len(results) == len(completed) == 1 and completed[0]["turn"]["id"] == turn_id - assert calls[0]["item"]["call_id"] == results[0]["item"]["call_id"] == proof["accepted_results"][index]["event"]["call_id"] - assert events.index(calls[0]) < events.index(results[0]) < events.index(completed[0]) < len(events) - 1 - assert events[-1]["type"] == "agent.session.idle" and results[0].get("output_index") is None - assert not any(event["type"] == "agent.session.turn.item.done" and event["item"]["type"] == "function_call_output" for event in events) - output = {field: results[0]["item"][field] for field in ("output", "error") if field in results[0]["item"]} - expected = {field: proof["accepted_results"][index]["event"][field] for field in ("output", "error") if field in proof["accepted_results"][index]["event"]} - assert output == expected - commands = [item for item in items if item.turn_id == turn_id and item.type == "command_execution" - and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] - assert len(commands) == 1 and commands[0].exit_code == 7 and commands[0].cwd == settings["workspace_directory"] - argv = shlex.split(commands[0].command) - assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./placement.sh " + phase] - answer = "\n".join(part.get("text", "") for item in items if item.turn_id == turn_id and item.type == "message" - and item.role == "assistant" for part in item.to_dict()["content"]) - assert memory in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer - if index == 1: - assert "remote-file-content" in answer and "Tool handler failed." in answer - proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] - proof["calls"] = [entry["event"]["call_id"] for entry in proof["accepted_results"]] - assert len(set(proof["calls"])) == 2 and len(handler_calls) == 2 - assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] - assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] - for submission in proof["accepted_results"]: - replay_result(submission) - changed = {**first_result["event"], "output": "changed"} - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [changed]}, headers={"Idempotency-Key": first_result["key"]}).status_code == 409 - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [first_result["event"]]}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 - time.sleep(1) - assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, order="asc", limit=100)) == items - for values in observations.values(): - ids = [value["event_id"] for value in values] - assert len(ids) == len(set(ids)) - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] - assert [value["turn"]["id"] for value in values if value["type"] == "agent.session.turn.completed"] == [turn.id for turn in turns] - raw_ids = [value["event_id"] for value in observations["raw"]] - start = next(index for index, value in enumerate(observations["sdk"]) if value["event_id"] in raw_ids) - common = observations["sdk"][start:] - assert common == observations["raw"][raw_ids.index(common[0]["event_id"]):], "SDK/raw common live suffix differs" - with client() as recovered: - resource = recovered.beta.agents.sessions - assert resource.retrieve(session_id).to_dict() == final - assert list(resource.turns.list(session_id, order="asc")) == turns - assert list(resource.items.list(session_id, order="asc", limit=100)) == items - proof["common_live_events"] = len(common) - proof["query_recovery"] = proof["retries_preserved_turns_and_items"] = True - proof["status"] = "public_functions_and_cold_continuation_verified" - print("Built service: real self-hosted functions, SDK success/error mapping, cold history, remote commands and original-result retry passed.", flush=True) - finally: - with lock: - proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) - proof["result_requests"] = list(submissions) - proof["input_responses"] = list(input_responses) - proof["helper_events"], proof["handler_calls"] = helper_events, handler_calls - write_private("public-functions-proof", proof) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_self_hosted_initial.py b/services/agents-api/tests/official_self_hosted_initial.py index a49bb61dc..9760ca502 100644 --- a/services/agents-api/tests/official_self_hosted_initial.py +++ b/services/agents-api/tests/official_self_hosted_initial.py @@ -34,6 +34,7 @@ def check(value, status, environment_id=None): assert environment["id"] == (environment_id or environment["id"]) assert environment["type"] == "self_hosted" and environment["capability_directories"] == [] assert environment["remote_url"] == settings["remote_url"] + assert environment["workspace_directory"] == "/workspace" action = {"type": "environment_connection", "environment_id": environment["id"]} assert value["required_actions"] == ([action] if status == "requires_action" else []) assert value["error"] == ("The initial input timed out waiting for the environment connection." if status == "failed" else None) @@ -62,7 +63,7 @@ def retry(case, status="requires_action"): phase = settings.get("phase", "create") if phase == "create": - environment = {"type": "self_hosted", "workspace_directory": "/private-initial-workspace"} + environment = {"type": "self_hosted", "workspace_directory": "/workspace"} inline = {"agent": {"model": "test-model", "instructions": "Retain the creation snapshot."}, "environment": environment} ordered = [{"role": "user", "content": [{"type": "input_text", "text": "first private input"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "second private input"}]}] diff --git a/services/agents-api/tests/official_self_hosted_steering.py b/services/agents-api/tests/official_self_hosted_steering.py deleted file mode 100644 index 12361f4f5..000000000 --- a/services/agents-api/tests/official_self_hosted_steering.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Public text admission with controlled active Turns and offline preparation.""" - -from concurrent.futures import ThreadPoolExecutor -import importlib.metadata -import json -from pathlib import Path -import sys -import threading -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import APIStatusError, OpenAI - - -def main(): - settings = json.load(sys.stdin) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source["vcs_info"]["commit_id"] == pin["commit"] - base, token = settings["base"], settings["token"] - headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} - - def client(): - return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=10)) - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - with client() as api, httpx2.Client(trust_env=False, timeout=10, headers=headers) as raw: - sessions = api.beta.agents.sessions - endpoint = base + "/v1/agents/sessions/" - - def submit(session_id, events, key, expected=204, sdk=False, key_token=token): - if sdk: - with client() as caller: - try: - response = caller.beta.agents.sessions.events.with_raw_response.create( - session_id, events=events, idempotency_key=key) - assert response.status_code == expected == 204 and response.content == b"" - assert response.parse() is None - except APIStatusError as error: - assert error.status_code == expected and expected != 204 - else: - response = raw.post(endpoint + session_id + "/events", json={"events": events}, - headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) - assert response.status_code == expected, (response.status_code, expected) - if expected == 204: - assert response.content == b"" - - def current(session_id): - value = sessions.retrieve(session_id).to_dict() - response = raw.get(endpoint + session_id) - assert response.status_code == 200 and response.json() == value - assert value["environment"]["type"] == "self_hosted" - return value - - def waiting(session_id, events, key): - try: - raw.post(endpoint + session_id + "/events", json={"events": events}, - headers={"Idempotency-Key": key}, timeout=0.8) - raise AssertionError("offline input returned before preparation") - except httpx2.ReadTimeout: - pass - - phase = settings["phase"] - if phase == "create": - request = {"agent": {"model": "test-model", "instructions": "Controlled active text."}, - "environment": {"type": "self_hosted", "workspace_directory": "/private-steering-workspace"}} - main_session = sessions.create(**request) - initial = sessions.create(**request, input="Keep initial pending input.") - later, deleted = sessions.create(**request), sessions.create(**request) - sessions.delete(deleted.id) - result = {"id": main_session.id, "environment_id": main_session.environment.id, - "initial_id": initial.id, "later_id": later.id, "deleted_id": deleted.id, - "batch": [message("first active text"), message("second active text")], - "pending_event": message("Keep later pending input.")} - result.update({key: str(uuid.uuid4()) for key in ("batch_key", "pending_key", "idle_key", "rollback_key")}) - else: - result = settings["accepted"] - session_id, batch, key = result["id"], result["batch"], result["batch_key"] - before = current(session_id) - assert before["environment"]["id"] == result["environment_id"] - if phase == "active": - barrier = threading.Barrier(4) - - def submit_once(index): - barrier.wait(timeout=10) - submit(session_id, batch, key, sdk=bool(index % 2)) - - with ThreadPoolExecutor(max_workers=4) as workers: - list(workers.map(submit_once, range(4))) - assert before["status"] == current(session_id)["status"] == "in_progress" - turns = list(sessions.turns.list(session_id)) - assert len(turns) == 1 and turns[0].id == settings["turn_id"] - elif phase == "reject": - cancel = {"type": "agent.session.input.cancel"} - for events, code in ((list(reversed(batch)), 409), ([message("changed")], 409), - ([batch[0], {"type": "agent.session.input.message", "input": []}], 400), - ([batch[0], cancel], 400)): - for sdk in (True, False): - submit(session_id, events, key, code, sdk) - submit(session_id, batch, "foreign", 404, key_token=settings["foreign_token"]) - for sdk in (True, False): - submit(result["deleted_id"], batch, key, 404, sdk) - for pending_id in (result["initial_id"], result["later_id"]): - submit(pending_id, batch, key, 409, sdk) - pending = current(result["later_id"]) - waiting(result["later_id"], [result["pending_event"]], result["pending_key"]) - assert current(result["later_id"]) == pending - assert current(session_id) == before - elif phase == "rollback": - for sdk in (True, False): - submit(session_id, batch, result["rollback_key"], 500, sdk) - assert current(session_id) == before - elif phase == "idle": - assert before["status"] == "idle" - waiting(session_id, [message("New idle input.")], result["idle_key"]) - assert current(session_id)["status"] == "requires_action" - for sdk in (True, False): - submit(session_id, batch, key, sdk=sdk) - assert len(list(sessions.turns.list(session_id))) == 2 - else: - assert before["status"] == ("idle" if phase == "terminal" else "in_progress") - for sdk in (True, False): - submit(session_id, batch, key, sdk=sdk) - assert current(session_id) == before - listed = [item.to_dict() for item in sessions.items.list(session_id, order="asc")] - response = raw.get(endpoint + session_id + "/items", params={"order": "asc"}) - assert response.status_code == 200 and response.json()["data"] == listed - texts = ["".join(part["text"] for part in item["content"]) for item in listed if item["type"] == "message"] - expected = ["Controlled original work.", "first active text", "second active text"] - if phase in ("later", "idle"): - expected.append("Controlled original work.") - assert texts == expected - print(json.dumps(result)) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_self_hosted_steering_native.py b/services/agents-api/tests/official_self_hosted_steering_native.py deleted file mode 100644 index 53afa9046..000000000 --- a/services/agents-api/tests/official_self_hosted_steering_native.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Real public self-hosted active text, native application and cold continuation.""" - -import importlib.metadata -import json -import os -from pathlib import Path -import shlex -import sys -import threading -import time -import uuid - -sys.dont_write_bytecode = True - -import httpx2 -from openai import OpenAI -from official_environment_retrieve import verify_environment - - -def main(): - settings = json.load(sys.stdin) - base, token, foreign = (settings[name] for name in ("base", "token", "foreign_token")) - directory = Path(settings["evidence"]) - os.umask(0o077) - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"] - proof = {"case": "public_steering", "scope": "built service, real remote MiniMax, public active input and cold continuation", - "sdk_version": distribution.version, "sdk_commit": pin["commit"], "snapshots": {}, - "limits": ["204 acknowledges durable admission; Go separately verifies actual native Accepted and cursor", - "Written only releases the bounded fixture gate; no crash recovery or OS-quiescence claim"]} - observations = {"sdk": [], "raw": []} - ready = {name: threading.Event() for name in observations} - done = {name: threading.Event() for name in observations} - failures, lock = [], threading.Lock() - session_id = None - - def write_private(name, value): - data = json.dumps(value, indent=2) - assert token not in data and foreign not in data - temporary = directory / (name + ".tmp") - temporary.write_text(data) - temporary.chmod(0o600) - temporary.replace(directory / (name + ".json")) - - def client(): - return OpenAI(api_key=token, base_url=base + "/v1", max_retries=0, _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=360)) - - def wait_for(predicate, timeout, label): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - with lock: - if failures: - raise AssertionError("public steering observer or input failed") from failures[0] - value = predicate() - if value: - return value - time.sleep(0.025) - raise AssertionError(label + " timed out") - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def observe(name): - completed = set() - - def accept(value): - with lock: - observations[name].append(value) - kind = value["type"] - assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed", "agent.session.turn.cancelled") - if kind == "agent.session.turn.completed": - completed.add(value["turn"]["id"]) - return kind == "agent.session.idle" and len(completed) == 2 - - try: - if name == "sdk": - with client() as api: - with api.beta.agents.sessions.events.stream(session_id, timeout=450) as stream: - ready[name].set() - for event in stream: - if accept(event.to_dict()): - return - else: - with httpx2.Client(trust_env=False, timeout=450) as raw: - with raw.stream("GET", base + "/v1/agents/sessions/" + session_id + "/events", - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as response: - assert response.status_code == 200 and response.headers["content-type"] == "text/event-stream" - ready[name].set() - for line in response.iter_lines(): - if line.startswith("data: ") and accept(json.loads(line[6:])): - return - raise AssertionError("live stream ended before both real Turns") - except BaseException as error: - with lock: - failures.append(error) - finally: - done[name].set() - - try: - with client() as api, httpx2.Client(base_url=base + "/v1", trust_env=False, timeout=360, - headers={"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"}) as raw: - sessions = api.beta.agents.sessions - environment = {"type": "self_hosted", "workspace_directory": settings["workspace_directory"]} - instructions = "Use the native shell for requested commands. Command verification requires the exact supplied command argument. Never append echo, separators, wrappers or error recovery. Exit 7 is intentional and must remain the tool's exit status; do not turn it into exit 0. Keep waiting or polling a running command until it finishes. Integrate additional user text without restarting or cancelling that command." - creation = {"agent": {"model": "MiniMax-M3", "instructions": instructions, "tools": []}, "environment": environment} - creation_key = str(uuid.uuid4()) - created = sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}) - session_id, environment_id = created.id, created.environment.id - expected_environment = {**environment, "id": environment_id, "capability_directories": [], "remote_url": settings["remote_url"]} - assert created.environment.to_dict() == expected_environment - assert created.status == "idle" and created.required_actions == [] and created.usage is None - assert list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - assert sessions.create(**creation, extra_headers={"Idempotency-Key": creation_key}).id == session_id - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") - - def snapshot(name, status): - value = sessions.retrieve(session_id).to_dict() - assert value["environment"] == expected_environment and value["status"] == status and value["error"] is None - response = raw.get("/agents/sessions/" + session_id) - assert response.status_code == 200 and response.json() == value - proof["snapshots"][name] = value - return value - - def post_raw(event, key): - response = raw.post("/agents/sessions/" + session_id + "/events", json={"events": [event]}, - headers={"Idempotency-Key": key}) - assert response.status_code == 204 and response.content == b"" - - def running_command(turn_id, phase): - values = [item.to_dict() for item in sessions.items.list(session_id, order="asc", limit=100) - if item.turn_id == turn_id and item.type == "command_execution" and item.status == "in_progress" - and shlex.split(item.command)[1:] == ["-lc", "./gate.sh " + phase]] - if values: - assert len(values) == 1 and values[0]["cwd"] == settings["workspace_directory"] - return values[0] - return None - - for name in observations: - threading.Thread(target=observe, args=(name,), daemon=True).start() - wait_for(lambda: all(signal.is_set() for signal in ready.values()), 25, "live subscriptions") - exact = "The tool command argument must be exactly the text inside the backticks: no wrapper, no appended echo, no separators, no error recovery. Exit 7 is intentional; preserve that native exit status and do not retry." - first_text = "Run the exact native shell command `./gate.sh first` once. It waits for the fixture before producing stdout/stderr and exit 7. Keep waiting or polling until it finishes; do not finish the Turn, release the gate yourself, cancel or restart it. Additional user text may arrive while it runs; include it and the command output in your final answer. " + exact - first_event, first_key = message(first_text), str(uuid.uuid4()) - submitted = threading.Event() - - def submit_first(): - try: - with client() as submitting: - response = submitting.beta.agents.sessions.events.with_raw_response.create(session_id, events=[first_event], idempotency_key=first_key) - assert response.status_code == 204 and response.parse() is None - assert list(submitting.beta.agents.sessions.turns.list(session_id)), "204 before durable admission" - except BaseException as error: - with lock: - failures.append(error) - finally: - submitted.set() - - threading.Thread(target=submit_first, daemon=True).start() - wait_for(lambda: sessions.retrieve(session_id).status == "requires_action", 25, "offline message reservation") - pending = snapshot("offline", "requires_action") - assert pending["required_actions"] == [{"type": "environment_connection", "environment_id": environment_id}] - assert not submitted.is_set() and list(sessions.turns.list(session_id)) == [] and list(sessions.items.list(session_id)) == [] - write_private("waiting", {"session_id": session_id, "environment_id": environment_id, - "remote_url": settings["remote_url"], "creation_mode": "empty_later"}) - wait_for(lambda: (directory / "initial-connection-ready.json").exists(), 90, "executor connection") - verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "connected") - write_private("initial-connection-read", {"environment_id": environment_id}) - wait_for(submitted.is_set, 150, "initial input admission") - first_id = list(sessions.turns.list(session_id))[0].id - wait_for(lambda: (directory / "steer-ready.json").exists(), 150, "independent active remote command") - proof["first_active_command"] = wait_for(lambda: running_command(first_id, "first"), 15, "public active command") - assert sessions.turns.retrieve(first_id, session_id=session_id).status == "in_progress" - value = "festival-" + uuid.uuid4().hex - active_text = "The fictional festival name is " + value + ". Remember it without writing it to a file. Keep waiting for the current command without cancelling, restarting or changing it. Include this exact festival name and the command stdout/stderr in your final answer after the command finishes." - assert value not in json.dumps(creation) and value not in first_text - active_event, active_key = message(active_text), str(uuid.uuid4()) - response = sessions.events.with_raw_response.create(session_id, events=[active_event], idempotency_key=active_key) - assert response.status_code == 204 and response.parse() is None - post_raw(active_event, active_key) - assert [turn.id for turn in sessions.turns.list(session_id)] == [first_id] - assert sessions.turns.retrieve(first_id, session_id=session_id).status == "in_progress" - - def steered_items(): - return [item.to_dict() for item in sessions.items.list(session_id, order="asc", limit=100) - if item.type == "message" and item.role == "user" and value in json.dumps(item.to_dict()["content"])] - - active_items = steered_items() - assert len(active_items) == 1 and active_items[0]["turn_id"] == first_id - proof["active_submission"] = {"event": active_event, "key": active_key, "status": 204, "item": active_items[0]} - write_private("steer-submitted", {"turn_id": first_id, "value": value}) - first_turn = wait_for(lambda: turn if (turn := sessions.turns.retrieve(first_id, session_id=session_id)).status == "completed" else None, - 150, "steered first Turn completion") - first_items = list(sessions.items.list(session_id, order="asc", limit=100)) - post_raw(active_event, active_key) - assert list(sessions.items.list(session_id, order="asc", limit=100)) == first_items - snapshot("first_completed", "idle") - write_private("first-completed", {"turn_id": first_id}) - wait_for(lambda: (directory / "resume-ready.json").exists(), 40, "retained native binding") - second_text = "Recall the fictional festival name supplied by the additional user message during the first Turn. Read retained.txt in a separate native tool call. Then run the exact native shell command `./gate.sh resumed` once in a new tool call. Do not combine the file read with this command. The command waits for the fixture; keep waiting or polling until it finishes and do not release its gate yourself. Include the remembered festival, retained file contents and command stdout/stderr in the final answer. " + exact - assert value not in second_text - second_event, second_key = message(second_text), str(uuid.uuid4()) - post_raw(second_event, second_key) - wait_for(lambda: (directory / "resumed-active.json").exists(), 150, "independent cold command activity") - turns = list(sessions.turns.list(session_id, order="asc")) - assert len(turns) == 2 and turns[0].status == "completed" and turns[1].status == "in_progress" - second_id = turns[1].id - proof["second_active_command"] = wait_for(lambda: running_command(second_id, "resumed"), 15, "public cold command") - before = list(sessions.items.list(session_id, order="asc", limit=100)) - assert sessions.events.create(session_id, events=[active_event], idempotency_key=active_key) is None - post_raw(active_event, active_key) - assert list(sessions.items.list(session_id, order="asc", limit=100)) == before - assert steered_items() == active_items and sessions.turns.retrieve(second_id, session_id=session_id).status == "in_progress" - write_private("old-steer-retried", {"turn_id": second_id}) - second_turn = wait_for(lambda: turn if (turn := sessions.turns.retrieve(second_id, session_id=session_id)).status == "completed" else None, - 150, "cold Turn completion") - wait_for(lambda: all(signal.is_set() for signal in done.values()), 30, "terminal live streams") - final = snapshot("final", "idle") - assert final["required_actions"] == [] - connected = wait_for(lambda: resource if (resource := api.beta.agents.environments.retrieve(environment_id, timeout=5)).status == "connected" else None, - 30, "executor reconnection after completion") - proof["final_environment"] = verify_environment(connected.to_dict(), environment_id, "connected") - turns, items = [first_turn, second_turn], list(sessions.items.list(session_id, order="asc", limit=100)) - for turn, phase in zip(turns, ("first", "resumed")): - commands = [item for item in items if item.turn_id == turn.id and item.type == "command_execution" - and "remote-stdout:" + phase in (item.output or "") and "remote-stderr:" + phase in (item.output or "")] - assert len(commands) == 1 and commands[0].exit_code == 7 and commands[0].cwd == settings["workspace_directory"] - argv = shlex.split(commands[0].command) - assert Path(argv[0]).name == "bash" and argv[1:] == ["-lc", "./gate.sh " + phase] - answer = "\n".join(part.get("text", "") for item in items if item.turn_id == turn.id and item.type == "message" - and item.role == "assistant" for part in item.to_dict()["content"]) - assert value in answer and settings["instruction"] in answer and "WRONG_LOCAL_INSTRUCTIONS" not in answer - if phase == "resumed": - assert "remote-file-content" in answer - assert steered_items() == active_items - proof["turns"], proof["items"] = [turn.to_dict() for turn in turns], [item.to_dict() for item in items] - assert raw.get("/agents/sessions/" + session_id + "/turns", params={"order": "asc"}).json()["data"] == proof["turns"] - assert raw.get("/agents/sessions/" + session_id + "/items", params={"order": "asc", "limit": 100}).json()["data"] == proof["items"] - for event, key in ((first_event, first_key), (active_event, active_key), (second_event, second_key)): - post_raw(event, key) - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [message("Changed active message")]}, headers={"Idempotency-Key": active_key}).status_code == 409 - assert raw.post("/agents/sessions/" + session_id + "/events", json={"events": [active_event]}, headers={"Authorization": "Bearer " + foreign}).status_code == 404 - time.sleep(1) - assert list(sessions.turns.list(session_id, order="asc")) == turns and list(sessions.items.list(session_id, order="asc", limit=100)) == items - for events in observations.values(): - ids = [event["event_id"] for event in events] - assert len(ids) == len(set(ids)) - assert [event["turn"]["id"] for event in events if event["type"] == "agent.session.turn.created"] == [turn.id for turn in turns] - assert [event["turn"]["id"] for event in events if event["type"] == "agent.session.turn.completed"] == [turn.id for turn in turns] - additions = [event for event in events if event["type"] == "agent.session.turn.item.added" and event["item"]["id"] == active_items[0]["id"]] - assert len(additions) == 1 and additions[0]["turn_id"] == first_id - raw_ids = [event["event_id"] for event in observations["raw"]] - start = next(index for index, event in enumerate(observations["sdk"]) if event["event_id"] in raw_ids) - common = observations["sdk"][start:] - assert common == observations["raw"][raw_ids.index(common[0]["event_id"]):], "SDK/raw common live suffix differs" - with client() as recovered: - resource = recovered.beta.agents.sessions - assert resource.retrieve(session_id).to_dict() == final - assert list(resource.turns.list(session_id, order="asc")) == turns - assert list(resource.items.list(session_id, order="asc", limit=100)) == items - proof["common_live_events"] = len(common) - proof["query_recovery"] = proof["old_input_did_not_retarget"] = True - proof["status"] = "public_active_input_and_cold_continuation_verified" - print("Built service: real self-hosted active input, exact retries, same Turn application, remote commands and cold history passed.", flush=True) - finally: - with lock: - proof["sdk_events"], proof["raw_events"] = list(observations["sdk"]), list(observations["raw"]) - write_private("public-steering-proof", proof) - - -if __name__ == "__main__": - main() diff --git a/services/agents-api/tests/official_user_runtime.py b/services/agents-api/tests/official_user_runtime.py new file mode 100644 index 000000000..cd7275c5e --- /dev/null +++ b/services/agents-api/tests/official_user_runtime.py @@ -0,0 +1,347 @@ +"""Public acceptance for an already enrolled, caller-owned Runtime. + +run_acceptance must run on the main thread of the Linux operator process. +The supplied Session is unused and its /workspace/outputs directory is empty. +The operator creates nonempty .user-runtime-isolation-canary files under +/home/runtime/.parsar/parsar-daemon and /environment/staging before calling. +The actual executor-key.json must remain under that protected daemon root. +runtime.read(path) returns bytes within a bounded timeout, raising FileNotFoundError only for absence. +runtime.restart() preserves workspace and native state and waits for reconnect. +runtime.restart_core() preserves database and API URL and waits for reconnect. +The caller owns service/model configuration, Runtime cleanup and actual execution. +""" + +import base64 +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +import hashlib +import importlib.metadata +import json +from pathlib import Path +import signal +import threading +import time +import uuid + +from openai import NotFoundError +from official_environment_files import verify_environment_files, verify_file_tenant_isolation +from official_session_artifacts import verify_session_artifacts + + +@contextmanager +def _prompt_deadline(): + # SSE keepalives do not extend the operator's wall-clock deadline. + def expired(signum, frame): + raise TimeoutError("Native Turn exceeded 240 seconds") + + previous = signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, 240) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + +def run_acceptance(client, foreign, http, session, runtime, evidence_path, secret_markers=()): + """Return a safe report after real execution; raise on any failed assertion.""" + assert threading.current_thread() is threading.main_thread(), "Run acceptance on the main thread" + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + assert distribution.version == pin["sdk_version"] and source.get("vcs_info", {}).get("commit_id") == pin["commit"], "Install the pinned SDK" + secrets = tuple(value.decode() if isinstance(value, bytes) else value + for value in (client.api_key, foreign.api_key, *secret_markers) if value) + assert all(isinstance(value, str) for value in secrets), "Secret markers must be strings or UTF-8 bytes" + evidence = Path(evidence_path) + evidence.mkdir(mode=0o700, parents=True, exist_ok=True) + sessions = client.beta.agents.sessions + sid, eid = session.id, session.environment.id + base = str(client.base_url).rstrip("/") + endpoint = base + "/agents/sessions/" + sid + headers = {"Authorization": "Bearer " + client.api_key, "OpenAI-Beta": "agents=v1"} + other_headers = {**headers, "Authorization": "Bearer " + foreign.api_key} + report = {"passed": False, "session_id": sid, "environment_id": eid, + "sdk_version": pin["sdk_version"], "sdk_commit": pin["commit"], "checks": [], "turns": []} + began = time.monotonic() + nonce = uuid.uuid4().hex + prefix = "/workspace/user-runtime-" + nonce + starts, ticks = prefix + "-starts", prefix + "-ticks" + memory = "remember-" + uuid.uuid4().hex + outputs = {"/workspace/outputs/a.bin": bytes(range(256)), + "/workspace/outputs/b.txt": ("native-user-runtime-" + nonce + "\n").encode()} + artifacts = {} + private_paths = ["/home/runtime/.parsar/parsar-daemon/executor-key.json", + "/home/runtime/.parsar/parsar-daemon/.user-runtime-isolation-canary", + "/environment/staging/.user-runtime-isolation-canary"] + + def private_hashes(): + values = {} + for path in private_paths: + content = runtime.read(path) + assert content, "Operator private-file witness is missing or empty" + values[path] = hashlib.sha256(content).hexdigest() + return values + + def redact(value): + if isinstance(value, str): + for secret in secrets: + value = value.replace(secret, "[REDACTED]") + return value + if isinstance(value, dict): + return {redact(key): redact(item) for key, item in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + return value + + def save(name, value): + path = evidence / name + with path.open("w") as stream: + path.chmod(0o600) + json.dump(redact(value), stream, indent=2) + + def safe(value): + assert redact(value) == value, "Public response exposed a private credential" + return value + + def until(check, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = check() + if value: + return value + time.sleep(0.2) + raise AssertionError("Runtime observation timed out") + + def read_optional(path): + try: + return runtime.read(path) + except FileNotFoundError: + return b"" + + def upload(path, data): + content = data.encode() if isinstance(data, str) else data + receipt = client.beta.agents.environments.files.create( + eid, type="inline", path=path, data=base64.b64encode(content).decode()).to_dict() + assert safe(receipt) == {"environment_id": eid, "object": "agent.environment.file", + "path": path, "size_bytes": len(content)}, "Upload metadata changed" + assert runtime.read(path) == content, "Public upload bytes differ in Runtime" + + def snapshot_items(name): + items = [item.to_dict() for item in sessions.items.list(sid, order="asc", limit=100)] + raw, after = [], None + while True: + params = {"order": "asc", "limit": 2} + if after: + params["after"] = after + response = http.get(endpoint + "/items", headers=headers, params=params) + assert response.status_code == 200, "Raw Items.list failed" + page = safe(response.json()) + raw.extend(page["data"]) + if not page["has_more"]: + break + assert page["data"] and page["data"][-1]["id"] != after, "Items cursor did not advance" + after = page["data"][-1]["id"] + assert len(raw) <= len(items), "Raw Items.list exceeded SDK result" + assert safe(items) == raw, "SDK and raw Items differ" + save(name + "-items.json", items) + return items + + def run_turn(text, number, cancellation=False): + observed, stop, requested = [], threading.Event(), threading.Event() + cancel_key = nonce + "-cancel" + + def cancel_running(): + deadline = time.monotonic() + 120 + while not stop.wait(0.2): + assert time.monotonic() < deadline, "Native command never began ticking" + current = read_optional(ticks) + if len(current.splitlines()) >= 3: + assert runtime.read(starts) == b"started\n", "Side-effect command was repeated" + requested.set() + client.with_options(timeout=10).beta.agents.sessions.events.create( + sid, events=[{"type": "agent.session.input.cancel"}], idempotency_key=cancel_key) + return len(current.splitlines()) + raise AssertionError("Cancellation ended before observing native effects") + + def consume(stream, journal, cancelling=None): + for event in stream: + value = event.to_dict() + journal.write(json.dumps(redact(value)) + "\n") + journal.flush() + safe(value) + observed.append(value) + kind = value["type"] + assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed"), "Native execution failed" + if cancelling is not None and cancelling.done(): + cancelling.result() + if kind == "agent.session.turn.cancelled": + assert cancellation and requested.is_set(), "Unexpected cancelled Turn" + cancelling.result(timeout=10) + break + assert kind != "agent.session.turn.completed" or not cancellation, "Long command completed before cancellation" + if kind == "agent.session.idle" and any(item["type"] == "agent.session.turn.completed" for item in observed): + break + terminal = "agent.session.turn.cancelled" if cancellation else "agent.session.turn.completed" + kinds = [item["type"] for item in observed] + assert kinds.count("agent.session.turn.created") == 1 and kinds.count(terminal) == 1, "Missing or duplicate Turn events" + assert kinds.index("agent.session.turn.created") < kinds.index(terminal), "Turn event order changed" + if not cancellation: + assert kinds[-1] == "agent.session.idle", "Completed Turn did not become idle" + + path = evidence / ("turn-" + str(number) + "-events.jsonl") + try: + with _prompt_deadline(), path.open("w") as journal: + path.chmod(0o600) + with sessions.events.stream(sid, timeout=240) as stream: + sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]}], idempotency_key=nonce + "-turn-" + str(number)) + if cancellation: + with ThreadPoolExecutor(max_workers=1) as pool: + cancelling = pool.submit(cancel_running) + try: + consume(stream, journal, cancelling) + finally: + stop.set() + report["ticks_before_cancel"] = cancelling.result() + else: + consume(stream, journal) + turns = list(sessions.turns.list(sid, order="asc", limit=100)) + assert len(turns) == number, "Input created an unexpected number of Turns" + turn = turns[-1] + assert turn.status == ("cancelled" if cancellation else "completed"), "Persisted Turn differs from SSE" + until(lambda: sessions.retrieve(sid).status == "idle") + items = snapshot_items("turn-" + str(number)) + current = [item for item in items if item.get("turn_id") == turn.id] + assert current, "Native Turn has no public Items" + if not cancellation: + assert all(item.get("status") not in ("failed", "incomplete", "in_progress") for item in current), "Completed Turn contains failed native work" + report["turns"].append({"id": turn.id, "status": turn.status, "event_count": len(observed)}) + return turn, current + except BaseException: + # No model request is retried; an already started tool is cancelled for cleanup. + try: + client.with_options(timeout=10).beta.agents.sessions.events.create( + sid, events=[{"type": "agent.session.input.cancel"}], idempotency_key=nonce + "-failure-cancel") + except Exception: + report["failure_cleanup_cancel_failed"] = True + raise + + def answer(items): + return "\n".join(part["text"] for item in items if item.get("type") == "message" and item.get("role") == "assistant" + for part in item.get("content", []) if part.get("type") == "output_text") + + def check_artifacts(): + values = verify_session_artifacts(client, foreign, http, sid, eid, artifacts) + safe([item.to_dict() for item in values]) + + try: + assert session.environment.type == "self_hosted" and session.environment.workspace_directory == "/workspace" + assert client.beta.agents.environments.retrieve(eid).status == "connected", "Runtime is not connected" + assert not list(sessions.turns.list(sid)) and not list(sessions.items.list(sid)), "Use an unused Session" + assert not list(sessions.artifacts.list(sid)), "Use a Session without existing Artifacts" + report["private_file_sha256"] = private_hashes() + isolation = ("from pathlib import Path\nimport hashlib,os\n" + f"protected={private_paths!r}\n" + f"secret_hashes={sorted({hashlib.sha256(value.encode()).hexdigest() for value in secrets})!r}\n" + "for path in protected:\n" + " try:\n with open(path,'rb') as f: exposed=f.read(1)\n" + " except (FileNotFoundError,PermissionError): continue\n" + " assert not exposed, 'Protected private bytes are visible'\n" + "for key,value in os.environ.items():\n" + " candidates=[value,value.removeprefix('Bearer ')]\n" + " assert all(hashlib.sha256(candidate.encode()).hexdigest() not in secret_hashes for candidate in candidates), 'Private credential is visible in tool environment'\n" + "proof={'protected_bytes_unreadable':True,'known_credentials_absent_from_environment':True}\n") + def isolation_call(phase): + return (f"proof=runpy.run_path({prefix + '-isolation.py'!r})['proof']\n" + f"with Path({prefix + '-isolation-' + phase + '.json'!r}).open('x') as f: json.dump(proof,f)\n") + publish = ("from pathlib import Path\nimport json,runpy\n" + isolation_call("first") + + f"p=Path({prefix + '-published'!r})\nwith p.open('ab') as f: f.write(b'published\\n')\n" + "root=Path('/workspace/outputs');root.mkdir(exist_ok=True)\nassert not any(root.iterdir())\n" + + "\n".join(f"with Path({path!r}).open('xb') as f: f.write({data!r})" for path, data in outputs.items()) + "\n") + hold = ("from pathlib import Path\nimport os,time\n" + f"with Path({starts!r}).open('ab') as f: f.write(b'started\\n');f.flush();os.fsync(f.fileno())\n" + f"with Path({ticks!r}).open('ab', buffering=0) as f:\n" + " while True:\n f.write(b'tick\\n');os.fsync(f.fileno());time.sleep(.2)\n") + recover = "from pathlib import Path\nimport json,runpy\n" + isolation_call("recovered") + resume = f"from pathlib import Path\nwith Path({prefix + '-resumed'!r}).open('xb') as f: f.write(b'resumed\\n')\n" + for suffix, script in (("-isolation.py", isolation), ("-publish.py", publish), ("-recover.py", recover), + ("-hold.py", hold), ("-resume.py", resume)): + upload(prefix + suffix, script) + report["checks"].append("public_files_create_exact_runtime_bytes") + + def isolation_proof(phase): + proof = json.loads(runtime.read(prefix + "-isolation-" + phase + ".json")) + assert proof == {"protected_bytes_unreadable": True, "known_credentials_absent_from_environment": True}, "Native isolation probe failed" + assert private_hashes() == report["private_file_sha256"], "Private witnesses changed across native execution or restart" + report["isolation_" + phase] = proof + + first, items = run_turn("Remember this exact conversation-only token, including the remember- prefix: " + memory + ". " + "Use your native shell tool to run exactly `python3 " + prefix + "-publish.py` once in the foreground. " + "Do not edit the script, delegate, retry the command, or repeat its side effects. Report any error without retrying.", 1) + assert any(item.get("type") == "command_execution" and item.get("status") == "completed" and prefix + "-publish.py" in item.get("command", "") for item in items), "Missing real native command observation" + isolation_proof("first") + assert runtime.read(prefix + "-published") == b"published\n", "Publish command was repeated" + for path, data in outputs.items(): + assert runtime.read(path) == data, "Native output bytes differ" + pages, page = verify_environment_files(client, http, eid, "/workspace/outputs", {path: len(data) for path, data in outputs.items()}) + save("files-list.json", safe(pages)) + verify_file_tenant_isolation(client, foreign, http, eid, "/workspace/outputs", page, list(outputs)) + artifacts[first.id] = outputs + check_artifacts() + for suffix in ("", "/items", "/turns", "/events"): + response = http.get(endpoint + suffix, headers=other_headers) + assert response.status_code == 404, "Foreign tenant accessed Session data" + safe(response.json()) + try: + foreign.beta.agents.sessions.items.list(sid) + except NotFoundError: + pass + else: + raise AssertionError("Foreign tenant accessed SDK Items") + committed = snapshot_items("before-restart") + report["checks"].append("native_outputs_files_artifacts_items_and_tenant_isolation") + runtime.restart() + runtime.restart_core() + assert client.beta.agents.environments.retrieve(eid).status == "connected", "Runtime did not reconnect" + assert snapshot_items("after-restart") == committed, "Restart changed committed Items" + second, items = run_turn("Run exactly `python3 " + prefix + "-recover.py` once with your native shell tool; do not edit or retry it. " + "Then return the entire exact conversation-only remember- token from our previous turn, including its prefix. " + "Do not look for the token in files or rerun any earlier command.", 2) + assert any(item.get("type") == "command_execution" and item.get("status") == "completed" and prefix + "-recover.py" in item.get("command", "") for item in items), "Missing recovered native isolation command" + isolation_proof("recovered") + assert memory in answer(items), "Cold continuation lost native conversation history" + assert runtime.read(prefix + "-published") == b"published\n", "Cold continuation repeated side effects" + artifacts[second.id] = outputs + check_artifacts() + report["checks"].append("runtime_and_core_restart_preserve_history_and_outputs") + third, items = run_turn("Use your native shell tool to run exactly `python3 " + prefix + "-hold.py` once and wait in the foreground. " + "It intentionally runs until cancelled. Do not background, delegate, retry, edit the script, or restart the command.", 3, cancellation=True) + commands = [item for item in items if item.get("type") == "command_execution" and prefix + "-hold.py" in item.get("command", "")] + assert len(commands) == 1 and commands[0].get("status") in ("failed", "incomplete"), "Cancelled command lost its native outcome" + stopped = runtime.read(ticks) + time.sleep(2) + assert runtime.read(ticks) == stopped and runtime.read(starts) == b"started\n", "Effects continued after cancellation settled" + sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}], idempotency_key=nonce + "-cancel") + assert len(list(sessions.turns.list(sid))) == 3, "Duplicate cancellation created work" + check_artifacts() + fourth, items = run_turn("Continue after the cancelled command. Never restart it or rerun the publish script. " + "Run exactly `python3 " + prefix + "-resume.py` once with your native shell tool, without retries. " + "Then return the entire original conversation-only remember- token, including its prefix.", 4) + assert any(item.get("type") == "command_execution" and item.get("status") == "completed" and prefix + "-resume.py" in item.get("command", "") for item in items), "Missing resumed native command observation" + assert memory in answer(items) and runtime.read(prefix + "-resumed") == b"resumed\n", "Post-cancel continuation failed" + assert runtime.read(starts) == b"started\n" and runtime.read(ticks) == stopped, "Continuation repeated cancelled effects" + assert runtime.read(prefix + "-published") == b"published\n", "Continuation repeated publication" + artifacts[fourth.id] = outputs + check_artifacts() + report["checks"].append("public_cancel_stops_ticks_duplicate_cancel_and_continuation_preserve_effect_counts") + report.update(passed=True, memory_sha256=hashlib.sha256(memory.encode()).hexdigest(), + output_sha256={path: hashlib.sha256(data).hexdigest() for path, data in outputs.items()}, + ticks_after_cancel=len(stopped.splitlines())) + return report + except BaseException as error: + report["failure_type"] = type(error).__name__ + raise + finally: + report["elapsed_seconds"] = round(time.monotonic() - began, 3) + save("result.json", report)