diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e89559807..15b0dc872 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,7 @@ copy, when a contract changes. | API callers, credentials and route inventory | [API index](docs/api/README.md) | | Public wire types and qualified behavior | [Agents API contracts](contracts/agents-api/README.md), [pinned upstream](contracts/agents-api/upstream.json), and linked operation contracts | | Core service implementation constraints | [Agents API implementation constraints](services/agents-api/IMPLEMENTATION.md) and [service README](services/agents-api/README.md) | +| Provider-to-Runtime startup input | [Runtime bootstrap](docs/runtime-bootstrap.md) and `internal/runtimebootstrap` | | Runtime messages, Executor/Turn lifetimes, receipts and failure ownership | [Core–Runtime protocol](docs/runtime-protocol.md) and `internal/agentdaemon/proto` | | Environment ownership and capability preparation (Skills, Plugins, MCP, `packages.system`) | [Environments](contracts/agents-api/environments.md) | | Adding a Harness (steps) | [Harness onboarding](contracts/agents-api/harness-onboarding.md), `apps/parsar-daemon/internal/agent/harness.go` and `internal/harnessconfig/harness.go` | @@ -205,7 +206,10 @@ The Runtime is not a sandbox; see Environment source. Platform support requires native CI builds and automated tests; cross-compilation alone is insufficient. - Evolve shared contracts and their implementations together, document - ownership and validate the same contract across implementations. This rule + ownership and validate the same contract across implementations. Each rule has + one authored definition; cross-language projections are generated from it or + checked against common fixtures. Bootstrap credentials use the Runtime-owned + launch input, never a Provider-authored private auth file. This rule does not claim every implementation already meets every target, change the pinned public API, or authorize unrelated refactors. @@ -213,6 +217,7 @@ The Runtime is not a sandbox; see | Boundary | Canonical guide | Code entry point | | --- | --- | --- | +| Provider–Runtime startup | [Runtime bootstrap](docs/runtime-bootstrap.md) | `internal/runtimebootstrap` | | Core–Runtime wire | [Core–Runtime protocol](docs/runtime-protocol.md) | `internal/agentdaemon/proto` | | Harness | [Harness onboarding](contracts/agents-api/harness-onboarding.md) | `apps/parsar-daemon/internal/agent/harness.go`, `internal/harnessconfig/harness.go` | | Sandbox Provider | [Sandbox Provider guide](docs/sandbox-provider.md) | `services/agents-api/internal/sandbox/sandbox_provider.go` | @@ -359,9 +364,7 @@ The dormant Pi adapter keeps its `parsar` provider slug because the separate Parsar product pins model selections to that identity. This is a product boundary exception for the name guard, like the skill-upload integration. -Build the MiniMax companion from this revision's patched native sources when -packaging a renamed Runtime. An older companion still uses the old -model-provider, workspace and subagent names and cannot be reused. +Build the MiniMax companion from this revision's pinned patched native sources. ## Branding diff --git a/Makefile b/Makefile index c2e1197e0..16bf3ecb1 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ check-go: check-runtime-contract: go test ./internal/agentdaemon/proto ./internal/agentdaemon/gateway ./apps/parsar-daemon/internal/transport ./apps/parsar-daemon/internal/dispatch ./apps/parsar-daemon/internal/contracttest -count=1 go test ./services/agents-api/internal/execution -run '^TestRuntimeProtocol' -count=1 + go test ./apps/parsar-daemon/internal/agent/... -run '^TestSharedTextLifecycle$$' -count=1 build-daemon: @set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/daemon"; \ diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 58c6a1fa6..7b2998099 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -25,7 +25,7 @@ The console endpoint and the public application endpoint serve different purpose even if they share a host. Use the [installation guide](/install) for deployment and the -[operations guide](/troubleshooting) for storage, upgrades and node +[operations guide](/troubleshooting) for storage, same-release repair and node management. A Core, Web and PostgreSQL installation may have zero execution nodes. Opening the console neither allocates compute nor invokes a model. Deployment sandbox management selects E2B, Docker or microsandbox independently of an diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 47351ceee..e65a8159c 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -177,9 +177,9 @@ Same-team E2B template, resource and key changes apply online through the Core A new allocations use the new generation, while existing sandboxes retain their original specification. Omit the key to preserve it; explicitly submitting a key, even the same value, verifies the replacement and advances the generation. Keep the -old key valid until the update succeeds. Initial setup requires a team-owned template; -a legacy public-template configuration or an already revoked old key requires reset -when Core cannot verify the committed ownership anchor. +old key valid until the update succeeds. Initial setup requires a team-owned template. +If Core cannot verify the committed ownership anchor, reset the deployment before +setting it up again. Docker and microsandbox size/Runtime edits advance the target generation online. They require neither zero held resources nor node retirement or reenrollment. @@ -190,9 +190,8 @@ preparing or has failed. Target rollout and serving readiness are separate facts Use **System** → **Sandbox backend** → **Change resources** to edit the target; **Nodes** owns node management and readiness. -Current Runtime generation coexistence and ownership-scoped garbage collection -remain supported. They do not upgrade the installed node program or convert an old -installation. History stays, and existing Sessions never move between providers. +Runtime generation coexistence and ownership-scoped garbage collection operate +within the installed node release. Existing Sessions retain their placement and history. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/sandbox-deployment.md). diff --git a/apps/docs/content/docs/development.mdx b/apps/docs/content/docs/development.mdx index 936c5b42b..ba09f0475 100644 --- a/apps/docs/content/docs/development.mdx +++ b/apps/docs/content/docs/development.mdx @@ -89,6 +89,7 @@ site; `pnpm dev:docs` starts its development server. | `services/agents-api/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](/runtime-protocol) | | `services/agents-api/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](/harness-onboarding) | | `internal/agentdaemon/proto` and `gateway` | Shared wire types, validators and authenticated Runtime connections | [Runtime protocol](/runtime-protocol) | +| `internal/runtimebootstrap` | Provider-to-Runtime startup input | [Runtime bootstrap](/runtime-bootstrap) | | `apps/parsar-daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](/harness-onboarding#required-adapter-interfaces) | | `apps/parsar-daemon/internal/agent` | Native harness adapters | [Native references](/harness-onboarding#native-references) | | `services/agents-api/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](/sandbox-provider) | @@ -112,6 +113,7 @@ only helps you pick the right one. | --- | --- | --- | | Harness adapter | A native agent engine behind the Runtime | [Harness onboarding](/harness-onboarding) | | Sandbox Provider | Outer compute that creates and reclaims Environments | [Sandbox Provider guide](/sandbox-provider) | +| Runtime bootstrap | Starting a managed Runtime with its connection identity | [Runtime bootstrap](/runtime-bootstrap) | | Core–Runtime protocol | A message, receipt or lifecycle rule between Core and the daemon | [Core–Runtime protocol](/runtime-protocol) | | Public API operation | A `/v1`, `/core/v1` or `/api/v1` route | [API index](/public-api) and [contracts](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/README.md) | | Environment capability | Skills, Plugins, MCP or `packages.system` preparation | [Environments](/environments-and-files#runtime-capability-preparation) | diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 3d5fb64b2..1979824a7 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -143,9 +143,8 @@ Mixed events and non-text input remain unsupported. Local capability directories use the same Runtime parser and installed snapshot as managed Skills and Plugins; preparation must finish before native execution. Reconnect reuses installed bytes, while new Sessions capture their own sources. Directory aliases and protected Runtime -state are rejected. The current implementation uses the packaged Linux Runtime -layout; cross-platform installation and isolation are not part of this profile. -The public field types are unchanged. Enrollment must match either the `/workspace` +state are rejected. Platform support and isolation follow +[Platforms and isolation](#platforms-and-isolation). Enrollment must match either the `/workspace` logical alias or the exact canonical directory bound by the Runtime; selecting an arbitrary path does not grant access. `remote_url` is the configured daemon WebSocket URL, returned unchanged. This is our private connection contract and does not claim @@ -189,7 +188,7 @@ compute replacement; never silently move a bound Session or replay unknown work. Self-hosted compute/files remain caller-owned, with explicit cleanup separate from Session deletion. The full Environment implementation remains pending; follow the [pinned contract and acceptance sequence](/environments-and-files) -and the [two-engine placement prerequisites](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/workspace-placement.md). +and the [workspace placement map](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/workspace-placement.md). For co-location, qualify the outer deployment boundary and the shared Runtime lifecycle. Native tools use the starting account's permissions; do not claim a daemon or harness sandbox. Host operators choose their own outer isolation. @@ -483,10 +482,8 @@ tools, files or network access. Outer Environments own managed isolation, and unsupported network restrictions reject instead of silently running unrestricted. Native installation and validation limits are in the -[native guide](/self-hosted-native). No project-version upgrade or -historical manifest compatibility is introduced. These rules do not establish -new real-deployment acceptance; historical evidence stays limited to its -recorded binaries and inputs. +[native guide](/self-hosted-native). Historical acceptance evidence +stays limited to its recorded binaries and inputs. ### Allocation initialization lifecycle diff --git a/apps/docs/content/docs/harness-onboarding.mdx b/apps/docs/content/docs/harness-onboarding.mdx index b0e4852b2..a46a55d4a 100644 --- a/apps/docs/content/docs/harness-onboarding.mdx +++ b/apps/docs/content/docs/harness-onboarding.mdx @@ -84,7 +84,12 @@ model communication configuration, not Turn scheduling or native process ownersh the [qualification table](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/harnesses.md#current-qualified-operations). Start with the mandatory text lifecycle, then qualify optional operations one at -a time. Do not copy an adapter's native limitations into the shared Core protocol. +a time. Call the reusable `agent/contracttest.TextLifecycle` assertions with the +adapter's prepared Executor and deterministic native fixture. These assertions +cover healthy reuse, durable input and cancellation; keep native fault and live +acceptance separate. Name the entry test `TestSharedTextLifecycle` so +`make check-runtime-contract` includes it. Do not copy an adapter's native +limitations into the shared Core protocol. ## Architecture rules @@ -215,12 +220,15 @@ never attribute a late result to whichever Turn is currently active. Initial input and steering use ordered `proto.MessageInput`. Preserve user-message and content order. Text-only adapters reject images through `TextOnly()` instead of dropping them. A successful transport write is distinct from confirmed native -application. Resume only the exact history bound to the Session; missing or -ambiguous required history fails before new model input. +application. User-choice answers use the emitted question ID and an array of +values; shared `PromptForUserChoiceDecisionPayload.AnswersFor` validates identity +before consuming a pending interaction. Do not map answers by header or position. +Resume only the exact history bound to the Session; missing or ambiguous required +history fails before new model input. | Interface or contract | When required | Obligation | | --- | --- | --- | -| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity | +| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity; independent of the optional non-durable `Steerer` | | `agent.FunctionResultSubmitter` | Public function tools | Match call/result identity and acknowledge native application | | `agent.PermissionResponder`, `agent.UserChoiceResponder` | When emitting these interactions | Route exact identities and settle receipts | | `agent.WorkspaceReader`, `agent.WorkspaceDirectoryLister`, `agent.WorkspaceWriter` | Qualified workspace operations | Use the fixed authorized workspace and retain accepted operations through close | diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 25ad6bfde..ea7a5090e 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -268,12 +268,10 @@ describes reset and API behavior. Same-team E2B edits have a separate online Cor API path and do not involve node installation. -## Historical node installations +## Installation scope -An older node installation is unsupported by the current installer. Retained state -under the old `parsar-node` names is a refusal condition, not an invitation to -convert or delete it. Preserve the old installation, Runtime resources and history; -provision a fresh node separately through **Add node**. The current installer does -not adopt historical resources or remove another installation's state. +Nodes and Core must come from the same distribution. Provision nodes through +**Add node**; the installer never adopts or removes another installation's +resources. See the [installation version policy](/troubleshooting#installation-version-policy). [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 61da23348..7ff7f2f37 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -30,6 +30,7 @@ "---Development---", "development", "harness-onboarding", + "runtime-bootstrap", "runtime-protocol", "sandbox-provider" ] diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 91d2ca8ac..ca6557cd6 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -11,7 +11,7 @@ public release content. | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`, `harness_config`, Session `installation`) | [Agents API guide](/sessions) | | `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core issues for hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/runtime.openapi.yaml) | A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in diff --git a/apps/docs/content/docs/runtime-bootstrap.mdx b/apps/docs/content/docs/runtime-bootstrap.mdx new file mode 100644 index 000000000..4dff42d41 --- /dev/null +++ b/apps/docs/content/docs/runtime-bootstrap.mdx @@ -0,0 +1,61 @@ +--- +title: "Runtime startup" +description: "Provider delivery of Runtime-owned connection input." +--- + +This document owns the Provider-to-Runtime startup boundary. The input type and +validator live in [runtimebootstrap](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/internal/runtimebootstrap/bootstrap.go). +Providers must not read or write Runtime's private authentication store. + +## Launch input + +Deliver one JSON object in a regular file accessible only to the Runtime account +and trusted provisioning processes (0600 on managed Linux). Pass its absolute path: + +```sh +oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json +``` + +| Field | Meaning | +| --- | --- | +| `version` | Exact bootstrap version declared by `runtimebootstrap.Version` | +| `core_url` | HTTP(S) machine API base ending in `/api/v1`, without credentials, query or fragment | +| `device_id` | Canonical nonzero UUID for the daemon identity issued by Core | +| `credential` | Nonempty daemon credential issued by Core, without whitespace or NUL | + +The decoder rejects unknown, duplicate, missing and case-aliased fields, other +versions and documents exceeding `runtimebootstrap.MaxBytes`. Errors exclude +submitted values. Go providers use `Bootstrap.RuntimeConnection()`; SDK helpers +forward the serialized object without defining their own authentication format. + +The file is the sole authentication input for this launch. It cannot be combined +with pairing or self-hosted enrollment flags. Credentials never go in command +arguments, environment variables or receipts. The provider retains the protected +file for process restarts and removes it only with explicit owned-resource cleanup. +Runtime reads it into memory and neither overwrites nor falls back to a private +auth profile. A missing or malformed file fails before connecting. + +## Responsibilities and readiness + +The Provider provisions the account, mounts and workspace, delivers this input, +sets the existing Runtime resource and Environment binding settings, and starts +the daemon as the unprivileged Runtime account. Docker supplies a file in its +owned home volume; microsandbox and E2B deliver it before launching the same +command. These are delivery mechanisms, not different bootstrap protocols. + +Runtime validates the input and owns authentication and connection establishment. +A successful process launch proves only handoff; authenticated connection, +capability preparation and execution readiness remain separate observations under +the [Core–Runtime protocol](/runtime-protocol). + +Self-hosted enrollment exchanges its executor credential for a daemon identity +through the machine API; it is a different source of authority, not a managed +bootstrap-file fallback. Both paths enter the same Runtime execution loop. + +## Verification + +`go test ./internal/runtimebootstrap ./apps/parsar-daemon/internal/cli` covers the +input contract, credential-source exclusivity and restart behavior. Provider tests +verify delivery and permissions without relying on private Runtime storage. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-bootstrap.md) diff --git a/apps/docs/content/docs/runtime-protocol.mdx b/apps/docs/content/docs/runtime-protocol.mdx index f49c40945..a21ce77db 100644 --- a/apps/docs/content/docs/runtime-protocol.mdx +++ b/apps/docs/content/docs/runtime-protocol.mdx @@ -142,11 +142,9 @@ that already owns the Executor. Connection shutdown owns transport-loss cleanup. Preserve the ten-second settlement wait and separate five-second receipt send budget; timeout is not proof of quiescence. The observed cancellation outcome retains native identity, Usage and output without fabricating missing evidence. -Codex native Turn interruption can leave background terminals alive. Its adapter -uses the exact thread-owned terminal list and confirmed per-terminal termination -before settling cancellation, and repeats this cleanup during Executor close. -The bulk clean acknowledgement does not prove termination. Failed cleanup keeps -the native owner available for a later close attempt. +Adapters must include owned background work in settlement and retain the exact +native cleanup target after failure. Native termination mechanisms belong to the +adapter; a bulk cleanup acknowledgement alone cannot establish quiescence. Private preparation controls reserve a per-Turn admission, not a new Executor. They carry an explicit Session identity and immutable configuration without model @@ -204,6 +202,14 @@ function-result acknowledgements use the Run ID. All application decision receipts additionally match the delivery ID. A reply without the required correlation cannot establish acceptance. +User-choice decisions carry `question_answers` with an explicit `question_id` +and an `answers` array for each provided answer. The IDs must belong to the emitted +questions and cannot repeat. Question order and display headers do not identify +answers; omitted questions remain unanswered and an empty array is an explicit +non-answer. Cancellation carries `cancelled: true` without answers. Shared +validation rejects other shapes before native submission. Adapters translate the +identified values into their native response without changing question identity. + ## Message families Use the linked source definitions for required fields, validators, limits and @@ -305,13 +311,10 @@ Workspace reads may request the private `workspace_read_only` preparation profil through the existing preparation factory and verified `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model/MCP credentials, native Session continuation and model/tool input are excluded. -The Codex adapter creates temporary local state, reuses its native connection and -directory transport, and rejects Start. Its child inherits only process/transport -essentials. The private native read mode excludes system, managed, user and project -execution configuration and plugin startup while preserving native security -requirements. Ordinary execution keeps its stable state and configuration. -Reject the legacy mixed managed-config profile for reads rather than discarding -its enforced constraints together with execution settings. +A read-only owner rejects Start and excludes model input, execution configuration +and plugin startup. A Runtime may satisfy this contract through its bound local +filesystem implementation; starting a native Harness process is not required. +Adapters that use native filesystem controls retain their own initialization rules. For this read profile, `released` is published only after local Close succeeds; cleanup errors retain ownership and report `cleanup_unconfirmed`. A failed factory must return its resource with the error if cleanup remains unconfirmed; wrappers @@ -446,10 +449,9 @@ 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. 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. +private interface. The Runtime binds access to the frozen local workspace, +prevents path escape, bounds enumeration and requires a complete validated result. +Filesystem readiness 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. @@ -464,11 +466,6 @@ pending operation IDs cannot start another read; this control does not promise d idempotency or result recovery. Preparation/Run ownership, public path authorization, 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; `environments.status` and its Session Environment-event snapshot commit together. @@ -509,4 +506,13 @@ controlled-adapter test establishes the transport contract, not native Harness behavior, OS support, provider authentication or sandbox isolation. Update the shared types, this guide and the contract checks together when semantics change. +The reusable [Harness text assertions](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/apps/parsar-daemon/internal/agent/contracttest/text.go) +accept any prepared Executor and a small fixture supplying deterministic normal, +active and steering inputs. They check independent Turn streams, native owner and +history continuity, durable write/application receipts, stale cancellation and +healthy continuation after cancellation. The Claude Go adapter runs them against +its controlled native subprocess fixture. New adapters can call the same assertions; +no optional feature is implied. Native failures, uncertain cleanup and exact-history +recovery still require the adapter's fault and real-provider acceptance tests. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-protocol.md) diff --git a/apps/docs/content/docs/sandbox-provider.mdx b/apps/docs/content/docs/sandbox-provider.mdx index 91295384d..8211efff1 100644 --- a/apps/docs/content/docs/sandbox-provider.mdx +++ b/apps/docs/content/docs/sandbox-provider.mdx @@ -237,6 +237,10 @@ Node tests separately exercise disconnect/reconnect fencing and cleanup after a lost create response. Provider helper protocols and the node protocol require an exact version match and reject mismatches; do not add fallback decoders or old binary migration. Direct in-process interfaces have no independent wire version. +Node generation management is an explicit current hello capability, not another +wire version. Fixed-configuration manual nodes use the same protocol and only +serve their enrolled deployment generation. See the +[node contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/node-generation-protocol.md). Run `make check` with its dedicated database before completion. Retain native acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 8644af430..fa21d6eef 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -122,12 +122,10 @@ through Core's loopback port. This is not required for one-command onboarding. See the [credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-executor-credentials.md) for those routes and uncertain-response handling. -## Historical executor installations +## Installation scope -The native installer supports its current version only. It does not adopt or -upgrade an older daemon or container installation. Preserve old files, containers -and native history; use a fresh Runtime home and Session when moving versions. -Historical container-installer instructions and qualification records describe -those earlier artifacts, not acceptance of the current native daemon. +The [native installation guide](/self-hosted-native#add-harnesses-and-operate-the-installation) +owns supported installation operations and component validation. Stopping a Runtime +or deleting a Session never removes the user's files or native history. [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index d13c6b29e..53167eb74 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -176,12 +176,8 @@ and mutating `oac` commands use the same installation lock, including during rep and interrupted apply recovery. If another command owns it, retry after it finishes. Never remove or replace `.oac.lock` to bypass a busy installation. -Historical installations and removed CLI or machine-protocol variants have no -migration procedure. Preserve their files, database, Runtime resources and Session -history; use a fresh installation and separately provision its nodes. Reinstallation -does not authorize deleting the previous installation's data. Retired process -settings remain rejected; use the current -[configuration reference](/configure). +Reinstallation never deletes another installation's files, database, Runtime +resources or Session history. ### Machine connections diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 73dbb4728..599bc1930 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -6,29 +6,30 @@ "docs/web/architecture.md": "7d5daf7745d2a25bf1113eea48512b085137f4f806c3fa0b6b16af63f7b18c4e", "docs/getting-started/install.md": "84b21002136acd4f006392685b7a071323336e87c1aac6e3c23ca56acb40833c", "docs/getting-started/install-options.md": "e03487bb6d978f84c596467028e25fd99fdc21cf2060af24ceb68df885084798", - "docs/configuration.md": "bb1b0ffe20f0589860a91887f35cea6ba2c423e59eeade78ea75dfa2619339b4", - "docs/web/core-connection.md": "6467595fde1c8931f27de76ac38762dfb5a7880651e859d9aa71e233ea95a9c1", + "docs/configuration.md": "680719abd401cf643c59c74c2840955fe7a64f17ca93e1e1ba80e40e473e675e", + "docs/web/core-connection.md": "861c75c32676fd17b84eb356b263096703af5750c1ceb71bb339e84607fffc56", "docs/getting-started/quickstart.md": "b989682ac2e58d59a794118fd371b37d1ea64c957d3512ff53739458a95d0f9a", - "docs/api/README.md": "ef2138c892b4de676e7b2b5622b46421204679e656dff7dcc181d261ef22009b", + "docs/api/README.md": "dbe3f172a997ee2fd3d2e5765d382b4fb7cf7c50f1dd17212ab9646f6959d63e", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "00979732412a971013e8f01b4c74820ff51aafdded6b0f105d25a78af86a6627", "docs/examples.md": "0e1bdaeff51c9c36779f817be31ea9816b7d8cb2290cf8350d3c4801f436f4f9", - "contracts/agents-api/environments.md": "d732a0456e54dc507c94d440cd32055670e341cb8248e40767785129982bd789", - "docs/getting-started/nodes.md": "13e1f1e867d3e361ff860960ceacc2dd5806e64f4176cd33f5e49f9a9f4d57e4", - "docs/getting-started/self-hosted.md": "4591e05bfa7657f647bedecd9db07b7086acd94b5548511c88f7e5e3567fdb11", + "contracts/agents-api/environments.md": "392b1d39969867f02d7bc7bd238c10211276ac20d679650980264281ff89353e", + "docs/getting-started/nodes.md": "c1ad18c445990ea696625011cc3cfa0725779beefd379064d278656801d5578e", + "docs/getting-started/self-hosted.md": "eda02de55559b60dc2e38da3c24a26bfe2be38db386c7d7861ccc921dd26aa51", "docs/self-hosted-native.md": "1d08e46b8406411f0efeab5940cbda907cd55afb8aec159f5233d2c0ef9d3d54", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "47159689b2488f0a94a1af8488bcf98b465e0cca4003d64a4699d7b4db24e086", "docs/api/web-management.md": "fe24e883f5745d262d3bd88eb73ae2cbbb723297b9a237f28849f9e3b50dffd4", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", - "docs/getting-started/operations.md": "f5c0942003373aaf604fc0f5bb88e0d022f465f1ade2db2192975a173efe7d95", + "docs/getting-started/operations.md": "4069e89deed7ef619f28cc8d9779055669246eabe322089113ac3a786c3cb2d5", "docs/user-guide.md": "190bea5bfe23b71db3d9437065ee270e07226a89a6e98c668853e5c7f7555529", "docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", - "docs/development.md": "18650cc33211d62ad06ecc31f9d759788059396f0a2e4c64077cd251b30d4f28", - "contracts/agents-api/harness-onboarding.md": "e512d85ef6569d32bfaa0d8f658ee41096d5250d00628eb98f7a94ed6f10a4f9", - "docs/runtime-protocol.md": "1ef452ca82c25ab8d1b4b97893a0449b467edccd69f86c4233343358a1ea8b04", - "docs/sandbox-provider.md": "cd2791affb8404391577a63accbc5bd3c536b251849c6bdf3b16344452c9b8ce", - "apps/docs/scripts/guides.json": "f4b2e58b64a89068a68a58ed1d8fe75893ee09d415e87391121e0e99bb1fbe3f" + "docs/development.md": "e875acfce070e1b38186d67d774f802861417b0a4a3600c0b2d3653042c5d1d6", + "contracts/agents-api/harness-onboarding.md": "18d3ccfe989e2caa57f220430157c6b73d87f5fc8e31aba8cada0b7ab9653a8c", + "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", + "docs/runtime-protocol.md": "0e968e509236369dd4e840b94d9083372830c45b695ae3a71a56eba5bae01347", + "docs/sandbox-provider.md": "3def50dfeb69e5160124cb9c4577e107e6ca666b40e6da32843a1c33b2b3515b", + "apps/docs/scripts/guides.json": "3c3768fb94fd3d42464d4ce8c55724b9ba8360133c7cc19d513d8ec83a8e034f" }, "outputs": { "content/docs/index.mdx": "432dd8f02f72edb9f39b92d272719b146c5c1bf5b0429191d0f4a56932b92353", @@ -36,27 +37,28 @@ "content/docs/execution-model.mdx": "50133f3911c5ad801868e695ad52651e83809b80cbc887ee0bca25769ad32065", "content/docs/install.mdx": "6c3ec01d1b80cbce35d58e3f141b0fa832d6294a2ecc07c3b286ecc56ba66919", "content/docs/install-options.mdx": "aaafc209293a905d5b433511149aa1f0cc422bd31d5bb5b6cd7eec0d532ab8eb", - "content/docs/configure.mdx": "a81c433d58337bf14b5a2785c5c60b8f91accd1fb6a4a5916cbd9d560b828775", - "content/docs/bootstrap-projects-keys.mdx": "30ffbd71f718fe59bf2fc383cfb5b8d5b1e0b69d42e6f7a44f30024ec4e4b42d", + "content/docs/configure.mdx": "48682bc016a27331d6ac59defa33dbabd7696fd039eb4f3e0c63650b0970696d", + "content/docs/bootstrap-projects-keys.mdx": "91a6f62cbe41737adfce9e8ec56e6dc3e2fb0ec8f6b677576f941cdfb378dc47", "content/docs/quickstart.mdx": "d0ab9537ab68f6dd3109353e937a29c5d58ec3894b56a52873c9a1d79dfa60a6", - "content/docs/public-api.mdx": "07a8bd7309f1f5ffa2eaa7edb5823ff9dedb3d2ad728a02112febf1c92526b27", + "content/docs/public-api.mdx": "cd59f4a661f897d1a9dc6ff08a09f7b7504c355288bbf022eff9b70500a9865c", "content/docs/agents-and-tools.mdx": "b8a85fe92e19a71b7ad7c9357ce7ad0595d50c9190edb748ca834df615b056cb", "content/docs/sessions.mdx": "bb63d799ed90038836d652f3f866d0309822425114c6b2aa36654b8a991947f6", "content/docs/examples.mdx": "587061e65ab2e841d14980539ba94e2216d4e8135c948a852b9a8bb0359c85d7", - "content/docs/environments-and-files.mdx": "af7b08c06a7df2cb331ff9a8dc9223b510e603348a50f0a85f40959da270339c", - "content/docs/hosted-providers.mdx": "06f0b13b053965bbdd1bfb0a7e85d19b50987841e0512fa8331fc9a67ff71fe0", - "content/docs/self-hosted-execution.mdx": "161a9000634b4075a55960556322a1b535d8157ba0a4abbeaa84c85c7292ad33", + "content/docs/environments-and-files.mdx": "83f236b22d241122817e016fdc4794bebb8c713338205b4e5318f9d84786f2e7", + "content/docs/hosted-providers.mdx": "85696d88b76f1233db3d8db3266bb0ab4d297d795a44ede13cf34e5169524c68", + "content/docs/self-hosted-execution.mdx": "df9e886c0430ba2aefba6eb698a8c33e5cd9b4e5474cad72440e5b75cce2c60c", "content/docs/self-hosted-native.mdx": "b2c5b2bb2080b5338bbaa3469f196555be31089ee713d2b9f1be8d47fa7e29cc", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "a930ce36035de74f0c2bef71bed067fc2287ba1c70a485758bf100d484f6adfd", "content/docs/admin-api.mdx": "f43f4f1cac887bc4baa4968d76542a87ba1a6ddeef9259299b53cd98a4aea5e1", "content/docs/observability.mdx": "c81214e1865517a9163c48c7ce7c396f5490c9d162080dfa05fa5ebc147f6c8b", - "content/docs/troubleshooting.mdx": "f0291b91254a704cb7d490f5205306b229f194bd620f6b050c90c9b3cea8f8c6", + "content/docs/troubleshooting.mdx": "bccd725d2389a80b66caf1a1da80532bd68dd3d470bf851799d0114f84e0af25", "content/docs/user-guide.mdx": "7c099b2d787ce3d7876889c7cefd621840ec083f267eadf278214a83f11ba274", "public/images/source/docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", - "content/docs/development.mdx": "6ee802865af8145ae53eee776c8d08542fe3a7c5440e47f523d529c56a282467", - "content/docs/harness-onboarding.mdx": "91701766570b726bb0ee52a051c9b4cb6a5f19514336ca63fd556deaf920c198", - "content/docs/runtime-protocol.mdx": "f20c9db822d845c8766547b22aff62a086d7b659ce0fc75d5de1a53d3eabdacf", - "content/docs/sandbox-provider.mdx": "84c249b9b6017af67433afd2304f455fe530d6212e7a9a8bbb3208a81699e8d5" + "content/docs/development.mdx": "3238057db9027bb823dfc4fb6f98d3196da8567e9de2a23db8c3bd70fe0a5a7b", + "content/docs/harness-onboarding.mdx": "e7a172793d81e0dee89398ea34351617c66435a13f511f7fb29df3b6a8967fbf", + "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", + "content/docs/runtime-protocol.mdx": "75e7c85a2085cccedf453e8ee751316ba2ee22ed0559e4018ee82df1af805f48", + "content/docs/sandbox-provider.mdx": "5472ccc9dc965c48716b6d73ff74263b4373bab6b41a8ae30040b6181d2de5c0" } } diff --git a/apps/docs/scripts/guides.json b/apps/docs/scripts/guides.json index 69b1f3120..5ef23e97a 100644 --- a/apps/docs/scripts/guides.json +++ b/apps/docs/scripts/guides.json @@ -140,6 +140,12 @@ "title": "Add a Harness", "description": "Implement and validate a native Harness adapter." }, + { + "slug": "runtime-bootstrap", + "source": "docs/runtime-bootstrap.md", + "title": "Runtime startup", + "description": "Provider delivery of Runtime-owned connection input." + }, { "slug": "runtime-protocol", "source": "docs/runtime-protocol.md", diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask.go b/apps/parsar-daemon/internal/agent/claudecode/ask.go index 36faf314b..29b5fc404 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask.go +++ b/apps/parsar-daemon/internal/agent/claudecode/ask.go @@ -51,18 +51,21 @@ func (p *pendingAskTable) RecordControl(askID, ccRequestID string, questions []p // re-doing the write. The double-fire risk is the bigger hazard here // (timer + server can both reach Submit; stdin flakes are rare and the // session is going to die anyway when stdin errors), so we accept it. -func (p *pendingAskTable) Take(askID string) (pendingAskEntry, bool) { +func (p *pendingAskTable) Take(askID string, decision proto.PromptForUserChoiceDecisionPayload) (pendingAskEntry, bool, error) { if askID == "" { - return pendingAskEntry{}, false + return pendingAskEntry{}, false, nil } p.mu.Lock() defer p.mu.Unlock() e, ok := p.byAskID[askID] if !ok { - return pendingAskEntry{}, false + return pendingAskEntry{}, false, nil + } + if _, err := decision.AnswersFor(askQuestionIDs(e)); err != nil { + return pendingAskEntry{}, false, err } delete(p.byAskID, askID) - return e, true + return e, true, nil } // interceptAskUserQuestionFromControlRequest handles the can_use_tool check @@ -167,7 +170,11 @@ func parseAskUserQuestionInput(input map[string]any) ([]proto.PromptForUserChoic // execution skips Claude's local AskUserQuestion handler; the SDK supplies the // message to the model as the tool result, including cancellation instructions. func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - text := formatAskUserResultText(entry, decision) + answers, err := decision.AnswersFor(askQuestionIDs(entry)) + if err != nil { + return nil, err + } + text := formatAskUserResultText(entry, decision, answers) body, err := json.Marshal(map[string]any{ "type": "control_response", "response": map[string]any{ @@ -186,7 +193,7 @@ func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptFor } // formatAskUserResultText formats answers or cancellation instructions for Claude. -func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) string { +func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload, answers map[string][]string) string { if decision.Cancelled { reason := strings.TrimSpace(decision.Reason) switch reason { @@ -199,45 +206,10 @@ func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUser } } - // Multi-question path: stable QuestionID is authoritative. Positional - // pairing remains only as a compatibility fallback for older peers; - // Header is never a key because duplicate or blank headers are valid. out := make([]map[string]any, 0, len(entry.Questions)) anyAnswer := false - // Single-question + legacy Answers slice with multiple entries - // = the multi-select case the old callback shape used. Join - // them with the same "、" we render to the human so the model - // sees one merged answer string for that question. - if len(entry.Questions) == 1 && len(decision.QuestionAnswers) == 0 && len(decision.Answers) > 1 { - merged := strings.Join(decision.Answers, "、") - return mustMarshalAskQuestions([]map[string]any{{ - "header": entry.Questions[0].Header, - "answer": merged, - }}) - } - for i, q := range entry.Questions { - answer := "" - if i < len(decision.QuestionAnswers) { - answer = decision.QuestionAnswers[i].Answer - if len(decision.QuestionAnswers[i].Answers) > 0 { - answer = strings.Join(decision.QuestionAnswers[i].Answers, "、") - } - } - for _, candidate := range decision.QuestionAnswers { - if q.ID != "" && candidate.QuestionID == q.ID { - answer = candidate.Answer - if len(candidate.Answers) > 0 { - answer = strings.Join(candidate.Answers, "、") - } - break - } - } - // Legacy callback path: a single-question slot answered via - // the flat Answers slice. Multi-question slots always populate - // QuestionAnswers, so this branch is no-op for them. - if answer == "" && len(decision.Answers) > i { - answer = decision.Answers[i] - } + for _, q := range entry.Questions { + answer := strings.Join(answers[q.ID], "、") if answer != "" { anyAnswer = true } @@ -257,3 +229,11 @@ func mustMarshalAskQuestions(qs []map[string]any) string { payload, _ := json.Marshal(map[string]any{"questions": qs}) return string(payload) } + +func askQuestionIDs(entry pendingAskEntry) []string { + ids := make([]string, len(entry.Questions)) + for i, question := range entry.Questions { + ids[i] = question.ID + } + return ids +} diff --git a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go index a2334ab93..6dc999113 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/ask_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/ask_test.go @@ -182,8 +182,8 @@ func TestPendingAskTableTakeRace(t *testing.T) { // into a single human-friendly answer string. func TestBuildAskUserControlResponseMultiSelect(t *testing.T) { body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{CCRequestID: "cc_m", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Pick lens"}}}, - proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Safety", "Performance"}}, + claudecode.PendingAskEntry{CCRequestID: "cc_m", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Header: "Pick lens"}}}, + proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Safety", "Performance"}}}}, ) if err != nil { t.Fatalf("buildAskUserControlResponse: %v", err) @@ -194,26 +194,22 @@ func TestBuildAskUserControlResponseMultiSelect(t *testing.T) { } } -// TestBuildAskUserControlResponseMultiQuestionPositional locks in the -// positional pairing contract: when two questions share the same Header -// (or both are blank — claude-code treats `header` as optional), each -// question still gets its own answer back. A previous header-keyed map -// approach collapsed duplicates and fed the model the wrong answer. -func TestBuildAskUserControlResponseMultiQuestionPositional(t *testing.T) { +// Duplicate display headers cannot change the identity or ordering of answers. +func TestBuildAskUserControlResponseDuplicateHeaders(t *testing.T) { // Two questions with IDENTICAL headers — the realistic shape when the // model omits header entirely and both fall back to "". body, err := claudecode.BuildAskUserControlResponseForTest( claudecode.PendingAskEntry{ CCRequestID: "cc_pos", Questions: []proto.PromptForUserChoiceQuestion{ - {Header: "", Question: "q1"}, - {Header: "", Question: "q2"}, + {ID: "first", Header: "", Question: "q1"}, + {ID: "second", Header: "", Question: "q2"}, }, }, proto.PromptForUserChoiceDecisionPayload{ QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ - {Header: "", Answer: "A1"}, - {Header: "", Answer: "B1"}, + {QuestionID: "second", Answers: []string{"B1"}}, + {QuestionID: "first", Answers: []string{"A1"}}, }, }, ) @@ -371,8 +367,8 @@ func TestTranslateControlRequestAskUserQuestionIntercepted(t *testing.T) { // message is what the SDK surfaces to the model as the tool_result. func TestBuildAskUserControlResponseSingleSelect(t *testing.T) { body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{CCRequestID: "cc_req_xyz", Questions: []proto.PromptForUserChoiceQuestion{{Header: "Confirm delete"}}}, - proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Confirm"}}, + claudecode.PendingAskEntry{CCRequestID: "cc_req_xyz", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Header: "Confirm delete"}}}, + proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm"}}}}, ) if err != nil { t.Fatalf("buildAskUserControlResponse: %v", err) diff --git a/apps/parsar-daemon/internal/agent/claudecode/export_test.go b/apps/parsar-daemon/internal/agent/claudecode/export_test.go index 8397994e4..ed9fa5a20 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/export_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/export_test.go @@ -39,7 +39,8 @@ func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []p (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) } func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { - return (*pendingAskTable)(p).Take(askID) + entry, ok, _ := (*pendingAskTable)(p).Take(askID, proto.PromptForUserChoiceDecisionPayload{Cancelled: true}) + return entry, ok } func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { p.mu.Lock() diff --git a/apps/parsar-daemon/internal/agent/claudecode/session.go b/apps/parsar-daemon/internal/agent/claudecode/session.go index 87486c8c8..93b3e40fb 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/session.go +++ b/apps/parsar-daemon/internal/agent/claudecode/session.go @@ -385,7 +385,10 @@ func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, dec // delivered answer can both reach here, but only one wins. The // loser sees ok=false and returns ErrUnknownAsk — the router logs // and moves on. - entry, ok := s.askPending.Take(askID) + entry, ok, err := s.askPending.Take(askID, decision) + if err != nil { + return err + } if !ok { return agent.ErrUnknownAsk } diff --git a/apps/parsar-daemon/internal/agent/claudecode/session_test.go b/apps/parsar-daemon/internal/agent/claudecode/session_test.go index d38055f1c..b848a1dc2 100644 --- a/apps/parsar-daemon/internal/agent/claudecode/session_test.go +++ b/apps/parsar-daemon/internal/agent/claudecode/session_test.go @@ -550,7 +550,12 @@ func TestSessionAskUserQuestionRoundTrip(t *testing.T) { } if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ - Answers: []string{"Confirm delete"}, + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"wrong"}}}, + }); err == nil { + t.Fatal("accepted a foreign question ID") + } + if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm delete"}}}, }); err != nil { t.Fatalf("SubmitPromptForUserChoice: %v", err) } @@ -575,7 +580,7 @@ func TestSessionAskUserQuestionRoundTrip(t *testing.T) { } // Second submit must look unknown. - err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"x"}}) + err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"x"}}}}) if !errors.Is(err, agent.ErrUnknownAsk) { t.Errorf("second SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go index 8f981f826..5d1ff8959 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/contracttest" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -79,9 +80,10 @@ func runPersistentExecutorHelper() { } for scanner.Scan() { var command struct { - Type string `json:"type"` - TurnID string `json:"turn_id"` - Input proto.MessageInput `json:"input"` + Type string `json:"type"` + TurnID string `json:"turn_id"` + InputID string `json:"input_id"` + Input proto.MessageInput `json:"input"` } if json.Unmarshal(scanner.Bytes(), &command) != nil { return @@ -107,6 +109,10 @@ func runPersistentExecutorHelper() { } settle(false) case "steer": + if os.Getenv("SDK_EXECUTOR_MODE") == "text_contract" { + encode(bridgeEvent{Type: "input_applied", TurnID: active, InputID: command.InputID}) + continue + } // A full bridge write has happened, but no native input receipt exists. encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) case "turn_cancel": @@ -306,3 +312,17 @@ func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { for range out { } } + +func TestSharedTextLifecycle(t *testing.T) { + config, req := persistentConfig(t, "text_contract") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + contracttest.TextLifecycle(t, contracttest.TextFixture{ + Executor: owner, + CompleteInput: proto.TextInput("hello"), ActiveInput: proto.TextInput("wait"), SteeringInput: proto.TextInput("continue waiting"), + Ready: func(e proto.Envelope) bool { return e.Type == proto.TypeDelta }, + NativeOwner: func() string { return strconv.Itoa(owner.(*executor).base.process.Cmd.Process.Pid) }, + }) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go index 09045a612..ef8763585 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go @@ -64,7 +64,7 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { if err := event.DecodePayload(&tool); err != nil { t.Fatal(err) } - if tool.NativeItem != nil || tool.Observation == nil || tool.Observation.Kind != "function" { + if tool.Observation == nil || tool.Observation.Kind != "function" { t.Fatal("expected neutral observation") } if tool.Stage != "before" && tool.Stage != "after" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go index 362eda1ea..aa8511c0e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/live_linux_test.go @@ -226,7 +226,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { if err := event.DecodePayload(&tool); err != nil { t.Fatal(err) } - if tool.Observation == nil || tool.Observation.Kind != "function" || tool.NativeItem != nil || (tool.Stage != "before" && tool.Stage != "after") { + if tool.Observation == nil || tool.Observation.Kind != "function" || (tool.Stage != "before" && tool.Stage != "after") { t.Fatal("invalid live neutral function observation") } if tool.Stage == "after" { @@ -267,13 +267,18 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { _ = json.Unmarshal(event.Payload, &payload) proof.Text = payload.Content proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) - select { - case <-s.process.Done(): - default: - t.Fatal("daemon Done preceded process release") - } } } + // Done reports the Turn outcome; the direct factory closes its Executor + // after output settlement. Verify release at that boundary. + if _, err := s.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + select { + case <-s.process.Done(): + case <-time.After(5 * time.Second): + t.Fatal("direct factory retained its process after settlement") + } if !steeringAt.IsZero() { receipt := <-steeringReply if receipt.err != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go index 36dec3965..5b8a1f71e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/options.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/options.go @@ -64,7 +64,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return startRequest{}, nil, err } start.NativeModelOptions = compileNativeModelOptions(modelConfiguration.HarnessConfig) - if req.WorkspaceAuthoring || req.ObserveTools { + if req.WorkspaceAuthoring { return fail("requested capability is not available in the private SDK adapter") } if err := req.ValidateToolSearch(true); err != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index 4ca35e982..68159e908 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -107,7 +107,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "none", "functions", "mcp", "tools", "controls", "old-runtime"} { + for _, name := range []string{"run", "prompt", "conversation", "attachments", "authoring", "subagents", "workspace-missing", "none", "functions", "mcp", "controls", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() @@ -132,8 +132,6 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{} - case "tools": - req.ObserveTools = true case "controls": req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go index fad7426ab..c08947b4a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go @@ -22,7 +22,7 @@ func liveWorkspaceCommands(t *testing.T, runID string, events []proto.Envelope, continue } var call proto.ToolCallPayload - if terminal || event.ID != runID || event.DecodePayload(&call) != nil || call.ID == "" || call.NativeItem != nil || call.Observation == nil || call.Observation.Kind != "command" { + if terminal || event.ID != runID || event.DecodePayload(&call) != nil || call.ID == "" || call.Observation == nil || call.Observation.Kind != "command" { t.Fatal("invalid command frame or execution identity") } o := call.Observation diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go index b64ba3756..40c47708a 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -69,7 +69,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) s := &Session{executor: e, nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, observeTools: base.observeTools, + functions: functions, observeMessages: base.observeMessages, observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 5f7578418..1c14661d0 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -87,10 +87,10 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ - nativeHome: nativeHomeFromPlan(plan), - functions: functions, - observeMessages: req.ObserveMessages, - observeTools: req.ObserveTools, + nativeHome: nativeHomeFromPlan(plan), + functions: functions, + observeMessages: req.ObserveMessages, + observeToolObservations: req.ObserveToolObservations, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/parsar-daemon/internal/agent/codex/server_requests.go index 656ac6ca7..19c22deb9 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests.go @@ -25,7 +25,6 @@ type pendingCodexPermission struct { type pendingCodexAsk struct { rpcID any questionIDs []string - answerKeys []string timeout time.Duration timer *time.Timer } @@ -142,7 +141,6 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err askID := codexInteractionID("ask") questions := make([]proto.PromptForUserChoiceQuestion, 0, len(params.Questions)) questionIDs := make([]string, 0, len(params.Questions)) - answerKeys := make([]string, 0, len(params.Questions)) for index, question := range params.Questions { options := make([]proto.PromptForUserChoiceOption, 0, len(question.Options)) for _, option := range question.Options { @@ -157,7 +155,6 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err questionID = header } questionIDs = append(questionIDs, questionID) - answerKeys = append(answerKeys, header) questions = append(questions, proto.PromptForUserChoiceQuestion{ ID: questionID, Header: header, Question: question.Question, Options: options, IsOther: question.IsOther, IsSecret: question.IsSecret, @@ -170,7 +167,7 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err timeout = time.Duration(*params.AutoResolutionMs) * time.Millisecond } } - pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, answerKeys: answerKeys, timeout: timeout} + pending := pendingCodexAsk{rpcID: rpcID, questionIDs: questionIDs, timeout: timeout} s.interactions.mu.Lock() pending.timer = time.AfterFunc(pending.timeout, func() { s.expireCodexAsk(askID) }) s.interactions.asks[askID] = pending @@ -253,7 +250,14 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.asks[askID] + var answers map[string][]string if ok { + var err error + answers, err = decision.AnswersFor(pending.questionIDs) + if err != nil { + s.interactions.mu.Unlock() + return err + } delete(s.interactions.asks, askID) } s.interactions.mu.Unlock() @@ -277,34 +281,11 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse } return nil } - byID := make(map[string][]string, len(decision.QuestionAnswers)) - byHeader := make(map[string][]string, len(decision.QuestionAnswers)) - for _, answer := range decision.QuestionAnswers { - values := answer.Answers - if len(values) == 0 { - values = splitCodexAnswers(answer.Answer) - } - if answer.QuestionID != "" { - byID[answer.QuestionID] = values - } - if answer.Header != "" { - byHeader[answer.Header] = values - } - } result := ToolRequestUserInputResponse{Answers: make(map[string]ToolRequestUserInputAnswer, len(pending.questionIDs))} - for index, questionID := range pending.questionIDs { - values := byID[questionID] - if len(values) == 0 && index < len(pending.answerKeys) { - values = byHeader[pending.answerKeys[index]] - } - if len(values) == 0 && index < len(decision.QuestionAnswers) { - values = decision.QuestionAnswers[index].Answers - if len(values) == 0 { - values = splitCodexAnswers(decision.QuestionAnswers[index].Answer) - } - } - if len(values) == 0 && index == 0 && len(decision.Answers) > 0 { - values = decision.Answers + for _, questionID := range pending.questionIDs { + values := answers[questionID] + if values == nil { + values = []string{} } result.Answers[questionID] = ToolRequestUserInputAnswer{Answers: values} } @@ -373,17 +354,6 @@ func (s *Session) stopCodexInteractionTimers() { } } -func splitCodexAnswers(answer string) []string { - parts := strings.Split(strings.TrimSpace(answer), ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if value := strings.TrimSpace(part); value != "" { - out = append(out, value) - } - } - return out -} - func stringPointer(value *string) string { if value == nil { return "" diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go index 51babb1f0..3c144ba3f 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests_test.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests_test.go @@ -169,6 +169,12 @@ func TestCodexUserInputMapsAnswersByQuestionID(t *testing.T) { t.Fatalf("ask timeout = %v, want 2m", pending.timeout) } + if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ + QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"yes"}}}, + }); err == nil { + t.Fatal("accepted a foreign question ID") + } + // Invalid identity must leave the original native request pending. submitDone := make(chan error, 1) go func() { submitDone <- s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{ @@ -318,7 +324,7 @@ func TestCodexInteractionExpiryUnblocksRuntime(t *testing.T) { if reply.Error == nil || reply.Error.Code != -32001 { t.Fatalf("expiry reply = %+v", reply) } - if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"yes"}}); !errors.Is(err, agent.ErrUnknownAsk) { + if err := s.SubmitPromptForUserChoice(context.Background(), request.AskID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}); !errors.Is(err, agent.ErrUnknownAsk) { t.Fatalf("late input error = %v, want ErrUnknownAsk", err) } }) diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index f53a49261..8c520bf50 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -69,12 +69,12 @@ type Session struct { observeSubagentIdentities bool functions *functionCalls observeMessages bool - observeTools bool - observeToolObservations bool - runID string - cfg sessionConfig - out chan<- proto.Envelope - rpc *JSONRPCClient + + observeToolObservations bool + runID string + cfg sessionConfig + out chan<- proto.Envelope + rpc *JSONRPCClient cancelCtx context.Context cancelFn context.CancelFunc diff --git a/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go b/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go index 314d4b722..1c82ac51a 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/parsar-daemon/internal/agent/codex/session_notifications_test.go @@ -14,7 +14,7 @@ func TestRootNotificationIsolation(t *testing.T) { out := make(chan proto.Envelope, 64) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true, - observeTools: true, observeToolObservations: true} + observeToolObservations: true} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools.go b/apps/parsar-daemon/internal/agent/codex/session_tools.go index 977e19a82..5c9856397 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_tools.go +++ b/apps/parsar-daemon/internal/agent/codex/session_tools.go @@ -10,26 +10,22 @@ func (s *Session) sendItemEvents(events []proto.Envelope, notification json.RawM var native struct { Item json.RawMessage `json:"item"` } - if s.observeTools || s.observeToolObservations { + if s.observeToolObservations { if err := json.Unmarshal(notification, &native); err != nil { return } } for _, event := range events { - if (s.observeTools || s.observeToolObservations) && event.Type == proto.TypeToolCall { + if (s.observeToolObservations) && event.Type == proto.TypeToolCall { var tool proto.ToolCallPayload if err := event.DecodePayload(&tool); err != nil { return } - if s.observeToolObservations { - var err error - tool.Observation, err = normalizeToolObservation(tool.ID, tool.Stage, native.Item) - if err != nil { - s.emitTerminal("codex: invalid tool observation", true) - return - } - } else { - tool.NativeItem = native.Item + var err error + tool.Observation, err = normalizeToolObservation(tool.ID, tool.Stage, native.Item) + if err != nil { + s.emitTerminal("codex: invalid tool observation", true) + return } payload, err := json.Marshal(tool) if err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/session_tools_test.go b/apps/parsar-daemon/internal/agent/codex/session_tools_test.go index 95cadd44b..f7fff2c95 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_tools_test.go +++ b/apps/parsar-daemon/internal/agent/codex/session_tools_test.go @@ -21,61 +21,8 @@ func toolSnapshotFixtures() []string { } } -func TestToolSnapshotsPreserveNativeResultsOnlyWhenRequested(t *testing.T) { - for _, item := range toolSnapshotFixtures() { - var identity struct{ ID string } - if err := json.Unmarshal([]byte(item), &identity); err != nil { - t.Fatal(err) - } - t.Run(identity.ID, func(t *testing.T) { - var legacy []proto.Envelope - for _, enabled := range []bool{false, true} { - out := make(chan proto.Envelope, 4) - s := &Session{runID: "run", observeTools: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} - s.setThreadID("private-thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) - raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) - s.onItemStarted(raw) - s.onItemCompleted(raw) - if len(out) != 2 { - t.Fatalf("tool event count changed: %d", len(out)) - } - for i, stage := range []string{"before", "after"} { - event := <-out - var tool proto.ToolCallPayload - if err := event.DecodePayload(&tool); err != nil { - t.Fatal(err) - } - if event.Type != proto.TypeToolCall || event.ID != "run" || tool.ID != identity.ID || tool.Stage != stage { - t.Fatalf("tool identity/stage changed: %+v %+v", event, tool) - } - if !enabled { - if tool.NativeItem != nil || tool.Observation != nil || bytes.Contains(event.Payload, []byte("native_item")) { - t.Fatal("legacy request acquired tool snapshot") - } - legacy = append(legacy, event) - continue - } - var expected bytes.Buffer - if err := json.Compact(&expected, []byte(item)); err != nil { - t.Fatal(err) - } - if !bytes.Equal(tool.NativeItem, expected.Bytes()) { - t.Fatalf("native result lost or coerced: %s", tool.NativeItem) - } - tool.NativeItem = nil - payload, err := json.Marshal(tool) - if err != nil || !bytes.Equal(payload, legacy[i].Payload) { - t.Fatalf("legacy tool fields changed: %s, %v", payload, err) - } - } - } - }) - } -} - -func TestToolObservationsReplaceNativeSnapshotsWhenRequested(t *testing.T) { - for _, nativeSnapshots := range []bool{false, true} { +func TestToolObservationsOnlyWhenRequested(t *testing.T) { + for _, enabled := range []bool{false, true} { for _, item := range toolSnapshotFixtures() { var source struct{ ID string } if err := json.Unmarshal([]byte(item), &source); err != nil { @@ -83,7 +30,7 @@ func TestToolObservationsReplaceNativeSnapshotsWhenRequested(t *testing.T) { } t.Run(source.ID, func(t *testing.T) { out := make(chan proto.Envelope, 4) - s := &Session{runID: "run", observeTools: nativeSnapshots, observeToolObservations: true, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s := &Session{runID: "run", observeToolObservations: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} s.setThreadID("private-thread") s.onTurnStarted(json.RawMessage(`{"threadId":"private-thread","turn":{"id":"private-turn"}}`)) raw := json.RawMessage(`{"threadId":"private-thread","turnId":"private-turn","item":` + item + `}`) @@ -95,13 +42,19 @@ func TestToolObservationsReplaceNativeSnapshotsWhenRequested(t *testing.T) { for _, stage := range []string{"before", "after"} { event := <-out var tool proto.ToolCallPayload - if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage || tool.Observation == nil { + if event.DecodePayload(&tool) != nil || event.Type != proto.TypeToolCall || tool.ID != source.ID || tool.Stage != stage { t.Fatal(event) } - if tool.NativeItem != nil || bytes.Contains(event.Payload, []byte("native_item")) { - t.Fatal("duplicate native snapshot") + if bytes.Contains(event.Payload, []byte("native_item")) { + t.Fatal("engine-specific snapshot escaped adapter") + } + if !enabled { + if tool.Observation != nil { + t.Fatal("unrequested observation") + } + continue } - if stage == "before" && tool.Observation.Status != "in_progress" { + if tool.Observation == nil || stage == "before" && tool.Observation.Status != "in_progress" { t.Fatal(tool.Observation) } if source.ID == "mcp" && !bytes.Contains(tool.Observation.Output, []byte("9007199254740993")) { diff --git a/apps/parsar-daemon/internal/agent/contracttest/text.go b/apps/parsar-daemon/internal/agent/contracttest/text.go new file mode 100644 index 000000000..7cffea179 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/contracttest/text.go @@ -0,0 +1,170 @@ +// Package contracttest provides shared assertions for Harness adapter tests. +// Fixtures may use controlled native transports or real providers; the caller +// must state which. A controlled transport is not live native qualification. +package contracttest + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TextFixture supplies a prepared Executor and deterministic native inputs. +// CompleteInput finishes normally; ActiveInput emits a Ready observation and +// stays active for steering and cancellation. NativeOwner observes the adapter's +// retained native owner: a native Session, reusable SDK Query or owned process, +// as appropriate for that adapter. It must not report just the Go wrapper. This +// does not require every Harness to retain one process or one transport; native +// per-Turn transports may change while their Session owner remains the same. +// Neither callback may start, finish or replace a Turn on behalf of the adapter. +type TextFixture struct { + Executor agent.Executor + CompleteInput, ActiveInput, SteeringInput proto.MessageInput + Ready func(proto.Envelope) bool + NativeOwner func() string +} + +// TextLifecycle verifies ordinary reuse, exact Turn ownership, durable active +// input, confirmed cancellation and continuation of a healthy Executor. It asks +// for no optional tools, images, interactions, workspace or detailed Usage. +// Native fault injection and history recovery have separate adapter tests. +func TextLifecycle(t *testing.T, fixture TextFixture) { + t.Helper() + if fixture.Executor == nil || fixture.Ready == nil || fixture.NativeOwner == nil { + t.Fatal("text contract fixture requires executor, readiness and native ownership") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fixture.Executor.Close(ctx); err != nil { + t.Error(err) + } + }) + first, output := start(t, ctx, fixture, "contract-first", fixture.CompleteInput) + native := settled(t, ctx, first, output, true) + owner := fixture.NativeOwner() + if native == "" || owner == "" { + t.Fatal("missing observed native history or resource identity") + } + second, output := start(t, ctx, fixture, "contract-active", fixture.ActiveInput) + select { + case <-output.ready: + case <-ctx.Done(): + t.Fatal("active Turn never became ready") + } + if sameTurn(first, second) { + t.Fatal("StartTurn reused the previous Turn object") + } + if err := first.Cancel(ctx); err != nil { + t.Fatal(err) + } + steerer, ok := second.(agent.DurableSteerer) + if !ok { + t.Fatal("public text Turn must implement DurableSteerer") + } + var written atomic.Int32 + if err := steerer.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "contract-input", Input: fixture.SteeringInput, DurableReceipt: true}, func() { written.Add(1) }); err != nil { + t.Fatalf("active input after stale cancellation: %v", err) + } + if written.Load() != 1 { + t.Fatalf("expected one complete-write callback, got %d", written.Load()) + } + if err := second.Cancel(ctx); err != nil { + t.Fatal(err) + } + if got := settled(t, ctx, second, output, false); got != native { + t.Fatalf("cancellation changed history: %q", got) + } + third, output := start(t, ctx, fixture, "contract-after-cancel", fixture.CompleteInput) + if sameTurn(third, first) || sameTurn(third, second) { + t.Fatal("StartTurn reused a settled Turn object") + } + if got := settled(t, ctx, third, output, true); got != native { + t.Fatalf("continuation changed history: %q", got) + } + if fixture.NativeOwner() != owner { + t.Fatal("healthy Turns replaced the native owner") + } +} + +type captured struct { + id string + ready, closed chan struct{} + events []proto.Envelope // Read only after closed, which synchronizes the drain. +} + +func start(t *testing.T, ctx context.Context, f TextFixture, id string, input proto.MessageInput) (agent.Turn, *captured) { + t.Helper() + out := make(chan proto.Envelope, 64) + capture := &captured{id: id, ready: make(chan struct{}), closed: make(chan struct{})} + go func() { + defer close(capture.closed) + var ready sync.Once + for event := range out { + capture.events = append(capture.events, event) + if f.Ready(event) { + ready.Do(func() { close(capture.ready) }) + } + } + }() + turn, err := f.Executor.StartTurn(ctx, id, input, out) + if turn == nil { + close(out) + } + if err != nil || turn == nil { + t.Fatalf("StartTurn(%s): %v", id, err) + } + return turn, capture +} + +func settled(t *testing.T, ctx context.Context, turn agent.Turn, output *captured, normal bool) string { + t.Helper() + state, err := turn.AwaitSettlement(ctx) + if err != nil || !state.Reusable { + t.Fatalf("healthy Turn did not settle for reuse: %+v, %v", state, err) + } + select { + case <-output.closed: + case <-ctx.Done(): + t.Fatal("settled Turn retained its output stream") + } + terminals := 0 + var done proto.DonePayload + for _, event := range output.events { + if event.ID != output.id { + t.Fatalf("event belongs to %q, expected %q", event.ID, output.id) + } + if terminals != 0 { + t.Fatal("event followed terminal outcome") + } + if event.Type == proto.TypeError && normal { + t.Fatal("normal Turn emitted an error") + } + if event.Type == proto.TypeDone { + terminals++ + if err := event.DecodePayload(&done); err != nil { + t.Fatal(err) + } + } + } + if normal && terminals != 1 { + t.Fatalf("normal Turn emitted %d terminal outcomes", terminals) + } + if !normal { + done = turn.CancellationOutcome() + } + id, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string) + return id +} + +func sameTurn(a, b agent.Turn) bool { + return reflect.ValueOf(a).Comparable() && a == b +} diff --git a/apps/parsar-daemon/internal/agent/harness.go b/apps/parsar-daemon/internal/agent/harness.go index 9a61199d4..4d55d11bd 100644 --- a/apps/parsar-daemon/internal/agent/harness.go +++ b/apps/parsar-daemon/internal/agent/harness.go @@ -89,6 +89,7 @@ type Session interface { // Turn capabilities. Public profiles determine which operations are required. // DurableSteerer reports one complete write synchronously, then waits for the native receipt. +// It is independent of Steerer; the public text path requires only this interface. type DurableSteerer interface { SteerWithReceipt(context.Context, proto.PromptSteerPayload, func()) error } diff --git a/apps/parsar-daemon/internal/agent/mcode/questions.go b/apps/parsar-daemon/internal/agent/mcode/questions.go index 160186f33..6dcb99796 100644 --- a/apps/parsar-daemon/internal/agent/mcode/questions.go +++ b/apps/parsar-daemon/internal/agent/mcode/questions.go @@ -127,6 +127,9 @@ func (s *Session) SubmitPromptForUserChoice(_ context.Context, id string, decisi if !ok { return agent.ErrUnknownAsk } + if err := decision.Validate(); err != nil { + return err + } response := map[string]any{"action": "cancel"} if !decision.Cancelled { content, err := questionContent(pending, decision) @@ -144,22 +147,20 @@ func (s *Session) SubmitPromptForUserChoice(_ context.Context, id string, decisi } func questionContent(pending pendingQuestion, decision proto.PromptForUserChoiceDecisionPayload) (map[string]any, error) { - answers := decision.QuestionAnswers - if len(answers) == 0 && len(pending.Properties) == 1 { - for key := range pending.Properties { - answers = []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: key, Answers: decision.Answers}} - } + ids := make([]string, 0, len(pending.Properties)) + for id := range pending.Properties { + ids = append(ids, id) + } + if _, err := decision.AnswersFor(ids); err != nil { + return nil, err } content := map[string]any{} - for _, answer := range answers { + for _, answer := range decision.QuestionAnswers { property, ok := pending.Properties[answer.QuestionID] if !ok { return nil, fmt.Errorf("mcode: unknown input field") } values := append([]string{}, answer.Answers...) - if len(values) == 0 && answer.Answer != "" { - values = []string{answer.Answer} - } if len(values) == 0 { continue } diff --git a/apps/parsar-daemon/internal/agent/mcode/questions_test.go b/apps/parsar-daemon/internal/agent/mcode/questions_test.go index 4fff09ade..d6ba44a1b 100644 --- a/apps/parsar-daemon/internal/agent/mcode/questions_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/questions_test.go @@ -39,13 +39,13 @@ func TestQuestionnaireOtherUsesOneQuestion(t *testing.T) { answer = "Asia" want = map[string]any{"region__other": "Asia"} } - got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{Answers: []string{answer}}) + got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{answer}}}}) if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("multiple=%t custom=%t: got=%v err=%v", multiple, custom, got, err) } } if multiple { - got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{Answers: []string{"Europe", "Asia"}}) + got, err := questionContent(pending, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "region", Answers: []string{"Europe", "Asia"}}}}) want := map[string]any{"region": []string{"eu"}, "region__other": "Asia"} if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("combined choice and custom: got=%v err=%v", got, err) diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index 59b5c183d..6d9710ff6 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -87,20 +87,20 @@ func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, c Codex: proto.SupportedAgentKind{ Kind: "codex", Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - Steering: true, - DurableTurns: true, - DurableInputReceipts: true, - MessageImages: true, - FunctionResultImages: true, - FunctionTools: true, - MCPHTTPTools: true, - MCPHTTPBearerAuth: true, - MessageItems: true, - ToolItems: true, + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + Steering: true, + DurableTurns: true, + DurableInputReceipts: true, + MessageImages: true, + FunctionResultImages: true, + FunctionTools: true, + MCPHTTPTools: true, + MCPHTTPBearerAuth: true, + MessageItems: true, + ToolObservations: true, EnvironmentNone: true, WebSearchControl: true, diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index eeca011c9..6eb2c21a5 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -93,7 +93,7 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { } } caps := info.Capabilities - if caps.WorkspaceAuthoring || caps.Permissions || caps.ToolItems || caps.WebSearchControl || caps.TextVerbosity || !caps.DurableTurns || !caps.DurableInputReceipts || !caps.FunctionTools || !caps.EnvironmentNone { + if caps.WorkspaceAuthoring || caps.Permissions || caps.WebSearchControl || caps.TextVerbosity || !caps.DurableTurns || !caps.DurableInputReceipts || !caps.FunctionTools || !caps.EnvironmentNone { t.Fatalf("incorrect SDK capability scope: %+v", caps) } // Even a ready SDK must not acquire product write access through the wrapper. diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index dfdf69815..15be6f650 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -56,13 +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") + profile = fs.String("profile", paths.DefaultProfile, "profile name for paired credentials and pid/log files") background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") serverURL = fs.String("url", "", "Core 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") + bootstrapFile = fs.String("bootstrap-file", "", "absolute path to Provider-to-Runtime connection JSON") credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") ) if err := fs.Parse(args); err != nil { @@ -88,6 +89,16 @@ func runConnect(ctx *runContext, args []string) error { if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } + var bootstrapped *auth.Profile + if *bootstrapFile != "" { + if *serverURL != "" || *token != "" || *deviceName != "" || *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { + return errors.New("connect: bootstrap input cannot be combined with enrollment or pairing options") + } + bootstrapped, err = bootstrapProfile(*bootstrapFile) + if err != nil { + return 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") @@ -114,7 +125,7 @@ func runConnect(ctx *runContext, args []string) error { // Validate auth.json exists before forking so the error // surfaces in the user's terminal instead of the background // child's log. - if !inlinePair { + if !inlinePair && bootstrapped == nil { if _, err := auth.Load(*profile); err != nil { return fmt.Errorf("connect: %w", err) } @@ -135,9 +146,14 @@ func runConnect(ctx *runContext, args []string) error { return err } - prof, err := resolveConnectProfile(*profile, *serverURL, *token, *deviceName) - if err != nil { - return err + var prof auth.Profile + if bootstrapped != nil { + prof = *bootstrapped + } else { + prof, err = resolveConnectProfile(*profile, *serverURL, *token, *deviceName) + if err != nil { + return err + } } return mainLoop(ctx, *profile, prof, agentCLIs) diff --git a/apps/parsar-daemon/internal/cli/connect_bootstrap.go b/apps/parsar-daemon/internal/cli/connect_bootstrap.go new file mode 100644 index 000000000..00c715b55 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_bootstrap.go @@ -0,0 +1,22 @@ +package cli + +import ( + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +// The launch file is the sole credential source for this connection. Reopening +// it on process restart neither pairs again nor overwrites an auth profile. +func bootstrapProfile(path string) (*auth.Profile, error) { + raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) + if err != nil { + return nil, errors.New("connect: Runtime bootstrap file unavailable") + } + input, err := runtimebootstrap.Decode(raw) + if err != nil { + return nil, err + } + return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, nil +} diff --git a/apps/parsar-daemon/internal/cli/connect_bootstrap_test.go b/apps/parsar-daemon/internal/cli/connect_bootstrap_test.go new file mode 100644 index 000000000..2ee9a6950 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_bootstrap_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" +) + +func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} + if err := auth.Save("default", prior); err != nil { + t.Fatal(err) + } + input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret"} + raw, err := input.Marshal() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "connection.json") + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + for range 2 { + p, err := bootstrapProfile(path) + if err != nil || p.ServerURL != input.CoreURL || p.RuntimeID != input.DeviceID || p.RunnerCredential != input.Credential { + t.Fatal("failed bootstrap/restart", err) + } + } + got, err := auth.Load("default") + if err != nil || got != prior { + t.Fatal("private profile modified", err) + } + if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil { + t.Fatal(err) + } + if _, err = bootstrapProfile(path); err == nil || strings.Contains(err.Error(), input.Credential) { + t.Fatal("invalid input fell back or leaked") + } + if err = os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err = bootstrapProfile(path); err == nil { + t.Fatal("missing input fell back to private auth") + } +} + +func TestConnectBootstrapRejectsOtherCredentialSourcesBeforeSideEffects(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + ctx := &runContext{stdin: strings.NewReader(""), stdout: io.Discard, stderr: io.Discard} + for _, args := range [][]string{ + {"--url", "https://core.example", "--token", "private-token"}, + {"--remote", "wss://core.example/api/v1/agent-daemon/ws"}, + {"--credential-file", "/credential.json"}, + {"--environment-id", "foreign"}, {"unexpected"}, + } { + err := runConnect(ctx, append([]string{"--bootstrap-file", "/not-present"}, args...)) + if err == nil || !strings.Contains(err.Error(), "cannot be combined") || strings.Contains(err.Error(), "private-token") { + t.Fatal("mixed startup source accepted", err) + } + } + path, err := paths.AuthFile("default") + if err != nil { + t.Fatal(err) + } + if _, err = os.Stat(path); !os.IsNotExist(err) { + t.Fatal("created private state") + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index c3077655e..ab3ada305 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.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.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/dispatch/durable_only_test.go b/apps/parsar-daemon/internal/dispatch/durable_only_test.go new file mode 100644 index 000000000..0385507b9 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/durable_only_test.go @@ -0,0 +1,68 @@ +package dispatch_test + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type durableOnlyExecutor struct { + *reusableExecutor + applied atomic.Int32 +} + +func (e *durableOnlyExecutor) StartTurn(ctx context.Context, id string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn, err := e.reusableExecutor.StartTurn(ctx, id, input, out) + return &durableOnlyTurn{Turn: turn, applied: &e.applied}, err +} + +type durableOnlyTurn struct { + agent.Turn + applied *atomic.Int32 +} + +func (t *durableOnlyTurn) SteerWithReceipt(_ context.Context, _ proto.PromptSteerPayload, written func()) error { + written() + t.applied.Add(1) + return nil +} + +func TestPublicTextSteeringDoesNotRequireOptionalSteerer(t *testing.T) { + owner := &durableOnlyExecutor{reusableExecutor: &reusableExecutor{starts: make(chan *reusableTurn, 1)}} + router, sender := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) + admission := executorAdmission(t, router, sender, "prepare", executorRequest()) + startExecutorTurn(t, router, sender, "prepare", "run", admission) + turn := <-owner.starts + defer turn.finish() + input := proto.PromptSteerPayload{InputID: "extra", Input: proto.TextInput("follow-up"), DurableReceipt: true} + if err := router.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + for _, event := range sender.snapshot() { + var ack proto.PromptSteerAckPayload + if event.Type == proto.TypePromptSteerAck && event.DecodePayload(&ack) == nil && ack.InputID == input.InputID && ack.Accepted { + return true + } + } + return false + }, "durable input applied without optional Steerer") + if owner.applied.Load() != 1 { + t.Fatal("input was not applied exactly once") + } + input.DurableReceipt = false + input.InputID = "ordinary" + if err := router.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { + t.Fatal(err) + } + for _, event := range sender.snapshot() { + var ack proto.PromptSteerAckPayload + if event.Type == proto.TypePromptSteerAck && event.DecodePayload(&ack) == nil && ack.InputID == "ordinary" && ack.ErrorCode == "unsupported" { + return + } + } + t.Fatal("unimplemented optional Steerer was not rejected") +} diff --git a/apps/parsar-daemon/internal/dispatch/functions_native_test.go b/apps/parsar-daemon/internal/dispatch/functions_native_test.go index 89aa69c74..e9cea60c1 100644 --- a/apps/parsar-daemon/internal/dispatch/functions_native_test.go +++ b/apps/parsar-daemon/internal/dispatch/functions_native_test.go @@ -137,7 +137,7 @@ func TestNativeFunctionBridge(t *testing.T) { nativeID := "" for index := 0; index < 3; index++ { run := fmt.Sprintf("run-%d", index) - request := proto.PromptRequestPayload{AgentKind: "codex", Input: proto.TextInput("Look up ticket 42."), RunID: run, AgentStateKey: "native-functions", AgentSessionID: nativeID, StrictResume: true, ReleaseOnCompletion: true, DisableExecutionEnvironment: true, ObserveTools: true, + request := proto.PromptRequestPayload{AgentKind: "codex", Input: proto.TextInput("Look up ticket 42."), RunID: run, AgentStateKey: "native-functions", AgentSessionID: nativeID, StrictResume: true, ReleaseOnCompletion: true, DisableExecutionEnvironment: true, ObserveToolObservations: true, FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}, AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": model.URL + "/v1", "api_key": "synthetic-local-token"}}} env, _ := proto.NewEnvelope(proto.TypePromptRequest, run, request) diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go index ecba30e74..56818cd98 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go @@ -140,7 +140,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { } for _, decision := range []proto.Envelope{ mustEnv(t, proto.TypePermissionDecision, "permission", proto.PermissionDecisionPayload{DeliveryID: "permission-reply", Approved: true}), - mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "ask-reply", Answers: []string{"yes"}}), + mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "ask-reply", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}), } { if err := r.Handle(t.Context(), decision); err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go index fce4c8640..2e8c9b044 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -104,7 +104,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { case "choice": session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") - mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, Answers: []string{"yes"}}) + mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) case "steering": mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Input: proto.TextInput("continue")}) } @@ -137,12 +137,12 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { } assertDecisionAck(t, sender.recSender, "new-permission", false, "not_pending") case "choice": - late := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "late-choice", Answers: []string{"yes"}}) + late := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "late-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) if err := r.Handle(t.Context(), late); err != nil { t.Fatal(err) } assertDecisionAck(t, sender.recSender, "late-choice", true, "") - newRequest := mustEnv(t, proto.TypePromptForUserChoiceDecision, "new-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "new-choice", Answers: []string{"yes"}}) + newRequest := mustEnv(t, proto.TypePromptForUserChoiceDecision, "new-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "new-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) if err := r.Handle(t.Context(), newRequest); err != nil { t.Fatal(err) } @@ -259,7 +259,7 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { case "choice": session.out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "ask"}) waitFor(t, func() bool { return hasFrame(sender.recSender, proto.TypePromptForUserChoice, "run") }, "choice request") - mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, Answers: []string{"yes"}}) + mutation = mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask", proto.PromptForUserChoiceDecisionPayload{DeliveryID: sender.deliveryID, QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}) case "steering": mutation = mustEnv(t, proto.TypePromptSteer, "run", proto.PromptSteerPayload{InputID: sender.inputID, Input: proto.TextInput("continue")}) } diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go index db420f8dc..f51beec1c 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go @@ -178,7 +178,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi for _, mutation := range []proto.Envelope{ mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "call", Success: true, Content: functionResultContent("answer"), DeliveryID: "publication-function"}), mustEnv(t, proto.TypePermissionDecision, "publication-permission", proto.PermissionDecisionPayload{DeliveryID: "publication-permission", Approved: true}), - mustEnv(t, proto.TypePromptForUserChoiceDecision, "publication-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "publication-choice", Answers: []string{"yes"}}), + mustEnv(t, proto.TypePromptForUserChoiceDecision, "publication-choice", proto.PromptForUserChoiceDecisionPayload{DeliveryID: "publication-choice", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}}), } { if err := r.Handle(t.Context(), mutation); err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/dispatch/router_test.go b/apps/parsar-daemon/internal/dispatch/router_test.go index 3e5a0e21f..07de29e24 100644 --- a/apps/parsar-daemon/internal/dispatch/router_test.go +++ b/apps/parsar-daemon/internal/dispatch/router_test.go @@ -445,7 +445,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_abcd1234", proto.PromptForUserChoiceDecisionPayload{ - DeliveryID: "delivery-ask-1", Answers: []string{"yes"}, + DeliveryID: "delivery-ask-1", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, }) if err := h.router.Handle(context.Background(), dec); err != nil { t.Fatalf("prompt_for_user_choice_decision: %v", err) @@ -457,7 +457,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { if len(calls) != 1 || calls[0].id != "ask_abcd1234" { t.Fatalf("askCalls = %+v, want one ask_abcd1234", calls) } - if len(calls[0].decision.Answers) != 1 || calls[0].decision.Answers[0] != "yes" { + if len(calls[0].decision.QuestionAnswers[0].Answers) != 1 || calls[0].decision.QuestionAnswers[0].Answers[0] != "yes" { t.Errorf("answer payload mismatch: %+v", calls[0].decision) } assertDecisionAck(t, h.sender, "delivery-ask-1", true, "") @@ -500,7 +500,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") dec := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_xxxxxxxx", proto.PromptForUserChoiceDecisionPayload{ - DeliveryID: "delivery-ask-gone", Answers: []string{"yes"}, + DeliveryID: "delivery-ask-gone", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, }) if err := h.router.Handle(context.Background(), dec); err != nil { t.Fatalf("prompt_for_user_choice_decision: %v", err) @@ -535,7 +535,7 @@ func TestPromptForUserChoiceDecisionKeepsIndexOnTransientAgentError(t *testing.T waitFor(t, func() bool { return len(h.sender.snapshot()) >= 1 }, "prompt_for_user_choice forwarded") decision := mustEnv(t, proto.TypePromptForUserChoiceDecision, "ask_retry", proto.PromptForUserChoiceDecisionPayload{ - DeliveryID: "delivery-ask-retry", Answers: []string{"yes"}, + DeliveryID: "delivery-ask-retry", QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"yes"}}}, }) if err := h.router.Handle(context.Background(), decision); err != nil { t.Fatalf("first decision: %v", err) diff --git a/apps/parsar-daemon/internal/dispatch/steering.go b/apps/parsar-daemon/internal/dispatch/steering.go index c6636fa1b..5baa092be 100644 --- a/apps/parsar-daemon/internal/dispatch/steering.go +++ b/apps/parsar-daemon/internal/dispatch/steering.go @@ -92,16 +92,15 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr return &ack } session := state.session - steerer, ok := session.(agent.Steerer) - if !ok { - ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." - return &ack - } + steerer, supportsSteering := session.(agent.Steerer) if input.DurableReceipt { if _, ok := session.(agent.DurableSteerer); !ok || (!state.releaseOnCompletion && state.preparedHandoff == nil) { ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." return &ack } + } else if !supportsSteering { + ack.ErrorCode, ack.Error = "unsupported", "This engine does not support active-turn input." + return &ack } if state.steerBusy { ack.ErrorCode, ack.Error = "busy", "Another input is awaiting an engine receipt; retry this input later." diff --git a/apps/web/README.md b/apps/web/README.md index 40ac30ae2..ffd3edb82 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -69,11 +69,9 @@ TLS reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime daemons, with their own credentials) directly to Core and everything else, including `/core/v1`, to Web. Operator scripts call `/core/v1` on Core's loopback port. -Nodes and Core come from one distribution. Older nodes using the removed -`/core/v1/sandbox` paths are unsupported; preserve their installation and data and -use a separate fresh installation. There is no drained in-place upgrade or -historical re-enrollment procedure. Current-version Runtime generation rollout -retains its separate resource lifecycle. +Nodes and Core come from one distribution. Runtime generation rollout has a +separate resource lifecycle; see the +[installation version policy](../../docs/getting-started/operations.md#installation-version-policy). The Web manager offers no manual Core key entry outside sign-in, and Web refuses to start without its Core key file. It holds no Project API key and never calls diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 9f7920569..a26736c8d 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -15,8 +15,9 @@ the boundary; it does not imply that every upstream feature is implemented. `scripts/extract-agents-api-upstream.py` (run it with the pinned SDK installed). Contract tests require `openapi.yaml` and the live router to have exactly those method and path pairs, every query parameter to be official, and every other -field to sit inside `x_agents_core` on Agents and Sessions, whose only members are -`harness` and `model_provider`. +field to sit inside `x_agents_core` on Agents and Sessions. The +[API index](../../docs/api/README.md) identifies the extension fields; request and +response types live in [`v1/`](v1/). Parsar owns product Agents and Teams. This service owns upstream execution resources, including reusable Agents and protocol subagents. The OpenAI Agents @@ -31,10 +32,8 @@ with official observations separated from Core acceptance. ## Implementation direction Keep the independent service, authentication, PostgreSQL/sqlc persistence, -transactional admission and official-client test harness. Replace the parts that -let legacy daemon representations define execution semantics. Starting over is -permitted where a replacement is smaller and clearer; neither a wholesale rewrite -nor compatibility with the old private implementation is a goal. +transactional admission and official-client test harness. Shared Runtime contracts +define execution semantics; native representations stay inside adapters. Concentrate native configuration, structured input/output and Item translation in an execution adapter. The application core owns execution state and persistence; diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index b9f7928d7..2fb5989a6 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -140,9 +140,8 @@ Mixed events and non-text input remain unsupported. Local capability directories use the same Runtime parser and installed snapshot as managed Skills and Plugins; preparation must finish before native execution. Reconnect reuses installed bytes, while new Sessions capture their own sources. Directory aliases and protected Runtime -state are rejected. The current implementation uses the packaged Linux Runtime -layout; cross-platform installation and isolation are not part of this profile. -The public field types are unchanged. Enrollment must match either the `/workspace` +state are rejected. Platform support and isolation follow +[Platforms and isolation](#platforms-and-isolation). Enrollment must match either the `/workspace` logical alias or the exact canonical directory bound by the Runtime; selecting an arbitrary path does not grant access. `remote_url` is the configured daemon WebSocket URL, returned unchanged. This is our private connection contract and does not claim @@ -186,7 +185,7 @@ compute replacement; never silently move a bound Session or replay unknown work. Self-hosted compute/files remain caller-owned, with explicit cleanup separate from Session deletion. The full Environment implementation remains pending; follow the [pinned contract and acceptance sequence](environments.md) -and the [two-engine placement prerequisites](workspace-placement.md). +and the [workspace placement map](workspace-placement.md). For co-location, qualify the outer deployment boundary and the shared Runtime lifecycle. Native tools use the starting account's permissions; do not claim a daemon or harness sandbox. Host operators choose their own outer isolation. @@ -480,10 +479,8 @@ tools, files or network access. Outer Environments own managed isolation, and unsupported network restrictions reject instead of silently running unrestricted. Native installation and validation limits are in the -[native guide](../../docs/self-hosted-native.md). No project-version upgrade or -historical manifest compatibility is introduced. These rules do not establish -new real-deployment acceptance; historical evidence stays limited to its -recorded binaries and inputs. +[native guide](../../docs/self-hosted-native.md). Historical acceptance evidence +stays limited to its recorded binaries and inputs. ### Allocation initialization lifecycle diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index bdbd89d3b..3157d4c9c 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -81,7 +81,12 @@ model communication configuration, not Turn scheduling or native process ownersh the [qualification table](harnesses.md#current-qualified-operations). Start with the mandatory text lifecycle, then qualify optional operations one at -a time. Do not copy an adapter's native limitations into the shared Core protocol. +a time. Call the reusable `agent/contracttest.TextLifecycle` assertions with the +adapter's prepared Executor and deterministic native fixture. These assertions +cover healthy reuse, durable input and cancellation; keep native fault and live +acceptance separate. Name the entry test `TestSharedTextLifecycle` so +`make check-runtime-contract` includes it. Do not copy an adapter's native +limitations into the shared Core protocol. ## Architecture rules @@ -212,12 +217,15 @@ never attribute a late result to whichever Turn is currently active. Initial input and steering use ordered `proto.MessageInput`. Preserve user-message and content order. Text-only adapters reject images through `TextOnly()` instead of dropping them. A successful transport write is distinct from confirmed native -application. Resume only the exact history bound to the Session; missing or -ambiguous required history fails before new model input. +application. User-choice answers use the emitted question ID and an array of +values; shared `PromptForUserChoiceDecisionPayload.AnswersFor` validates identity +before consuming a pending interaction. Do not map answers by header or position. +Resume only the exact history bound to the Session; missing or ambiguous required +history fails before new model input. | Interface or contract | When required | Obligation | | --- | --- | --- | -| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity | +| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity; independent of the optional non-durable `Steerer` | | `agent.FunctionResultSubmitter` | Public function tools | Match call/result identity and acknowledge native application | | `agent.PermissionResponder`, `agent.UserChoiceResponder` | When emitting these interactions | Route exact identities and settle receipts | | `agent.WorkspaceReader`, `agent.WorkspaceDirectoryLister`, `agent.WorkspaceWriter` | Qualified workspace operations | Use the fixed authorized workspace and retain accepted operations through close | diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 045518efa..1c346e3e3 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -90,7 +90,9 @@ stopping conditions. A batch ends when its declared operations pass; it does not expand to match another Harness's feature list. A small adapter does not remove the need for native qualification. -- Adapter tests: two ordinary Turns share one native process/connection and history; +- Adapter tests: reuse `agent/contracttest.TextLifecycle` with a controlled native + fixture or real provider. Record which one was used. Two ordinary Turns share + one native process/connection and history; cancellation followed by another Turn; stale cancellation and late events; native exit, cleanup failure, input write/application receipts and unknown outcomes. Verify fresh per-Turn usage, function, input and child-observation state. diff --git a/contracts/agents-api/message-input.md b/contracts/agents-api/message-input.md index 89142914f..eb0eee56c 100644 --- a/contracts/agents-api/message-input.md +++ b/contracts/agents-api/message-input.md @@ -74,7 +74,7 @@ before. ## Runtime boundary -Private wire 0.8.0 uses `MessageInput` for initial requests, prepared start and +The current private wire uses `MessageInput` for initial requests, prepared start and active steering. Each message contains ordered `InputContent` parts, also reused by function-result content. Core does not download, transcode or repair media. The separate Claude bridge reports protocol 2; daemon readiness rejects protocol 1. diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index 1fdb2de57..00f5dbba9 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -1,13 +1,18 @@ # Node generation protocol -This is the internal, authenticated Core-to-node provider protocol. It does not -change the pinned public Agent API. Version 1 retains its existing envelope; -version 2 carries exact deployment-generation routing and sparse local provider -observations. A version 1 connection never receives version 2 fields. +This is the internal, authenticated Core-to-node Provider protocol. It does not +change the pinned public Agents API. `sandbox/node.ProtocolVersion` is the only +accepted wire version; both peers reject historical versions. The current hello +explicitly advertises `generation_management` when the node can prepare and retain +multiple deployment generations. Fixed-configuration manual nodes omit that +capability and serve only their enrolled generation using the same wire protocol. +Every Provider request carries its exact allocation-owned deployment generation; +Core never strips it for an older peer. Automatic preparation and retention frames +are sent only to nodes advertising generation management. ## Bounded control -A version 2 hello or heartbeat contains at most eight generation observations. +A generation-managing node's hello or heartbeat contains at most eight generation observations. Each names a positive signed-64-bit generation, its lowercase SHA-256 specification digest, a `ready`, `preparing` or `failed` state, and an optional fixed diagnostic. The target and serving generation take priority; other records rotate fairly. @@ -66,7 +71,7 @@ Neither opener adopts a missing identity, replaces its inode, or erases it after GC. Initialization interrupted before the identity is durable refuses re-adoption; preserve the installation for inspection. A removed identity or an owned replacement 0600 lease still refuses, even when its current fstat/lstat agree. -Historical v1 installations and their `legacy-unfenced` records are retained +Historical installations and their `legacy-unfenced` records are retained for inspection; the current installer does not adopt or upgrade them. Do not create a new fence to bypass a missing historical lease identity. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index a6937525f..7fe2f6582 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -528,3 +528,24 @@ supported and are independent of installed program version changes. See `runtime_download_failed` means the exact Runtime artifacts could not be transferred or verified. It is distinct from provider probe and image availability failures; the diagnostic never contains artifact URLs, credentials or transport output. + + +## Canonical node specification + +`sandbox/deployment_contract.go` owns resource bounds, provider requirements, +release patterns and canonical field order. `sandbox/deployment.go` applies those +rules in Core. The installer consumes the generated declaration in +`deploy/install/node_spec.py`; do not maintain a second set of limits or patterns. +Regenerate it from the repository root with +`go run ./services/agents-api/cmd/specification-contract -write`. +The sandbox Go tests, included in `make check`, reject a stale projection. + +The specification digest is SHA-256 of UTF-8 compact JSON, with `provider` first, +then `resources`, then `runtime` when required by the provider. Resource and +Runtime fields follow the contract declaration order. Zero optional disk fields +are omitted; required fields remain present. Release identities are lowercase +ASCII; the digest never hashes the incoming JSON field order or whitespace. +`internal/sandbox/testdata/deployment-contract.json` (under `services/agents-api/`) +contains shared acceptance cases, exact canonical bytes and digests consumed by +both Go and Python tests. Cross-language validation is required; distinct peers +must not invent distinct rules. diff --git a/contracts/agents-api/workspace-placement.md b/contracts/agents-api/workspace-placement.md index bdadd8d96..ed40579bf 100644 --- a/contracts/agents-api/workspace-placement.md +++ b/contracts/agents-api/workspace-placement.md @@ -1,6 +1,6 @@ # Workspace placement -V1 colocates daemon, selected harness, native tools and `/workspace` in one Runtime. +The daemon, selected harness, native tools and workspace are colocated 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-managed hosting uses the deployment-selected E2B, Docker or microsandbox @@ -17,134 +17,24 @@ implementation is recorded in the [Environment profile](environments.md#initial- [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. +that URL names our private daemon transport, not stock `exec-server`. 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 +## Contract owners -The assessment below records the earlier topology and native prerequisites at its -original scope. Its inner-sandbox prerequisites and denied private-file probes -are historical only and must not be reinstated as current daemon requirements. -It is not current installation guidance or proof of the current daemon chain. Referenced retired package/probe sources remain in Git history. +- [Environments](environments.md#ownership-and-placement-decision) owns placement, + preparation and authorization. +- [Core–Runtime protocol](../../docs/runtime-protocol.md) owns workspace operation + messages, bounds and operation lifetimes. +- [Environment Files](environment-files.md) records the public file behavior and + its qualification evidence. +- [Harness onboarding](harness-onboarding.md) owns native adapter integration. -### Current implementation and missing prerequisites +## Recorded output limitation -| Boundary | Codex | Claude Agent SDK | -| --- | --- | --- | -| Native pin | 0.153.4, commit `3d2ee51ca2d5db578f328aa75e20aa22c0197c9a` | SDK 0.3.269, native 2.1.269 | -| Public execution | `none` and the accepted `self_hosted` remote-executor profile | `none`, with built-in command/file tools disabled | -| Workspace placement | Separate native executor; harness cwd is not the remote workspace | Private typed factory binding inside a separately qualified outer placement; changing cwd alone is insufficient | -| Preparation | Ready before input promotion; Start retains the same native preparation | Private SDK/Go prepare-start ownership is qualified; public workspace admission remains closed | -| History | Retained native history on the bound device, separately from executor workspace | Managed native state and exact resume; private workspace continuation has explicit real-provider acceptance | -| Files | A shared native manager was proven privately; production transport/lifetime composition remains missing | Native tools can access a local workspace; public Files and an authorized idle owner remain missing | -| Cancellation | Owned-command cancellation verified; auxiliary process cleanup still has a recorded failure | Private workspace factory acceptance checks cancellation and effect cessation; arbitrary escaped descendants are not qualified | - -Keep Codex's accepted remote path. Qualify Claude's maintained `query()` entry -inside a dedicated execution environment, retaining the native model/tool loop. -Do not force every adapter through Codex's transport or introduce a replacement -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 - -There are two separate boundaries. The deployment excludes broader application, -daemon and other-tenant credentials from the harness environment and mounted -files. Within that deployment, native controls prevent generated operations from -reading selected model/MCP credentials or rewriting native history. Directory -bindings, a custom process spawner and permission callbacks alone prove neither. - -The pinned SDK exposes `SandboxSettings`, replacement `Options.env`, `Options.settings` -and `query()`. Linux Bash uses bubblewrap, separate user/PID namespaces and the -native executable's bundled seccomp helper. Native Read/Edit run in the trusted -harness and use permission rules. A sandbox `denyRead` entry is not automatically -a Read permission deny; the documented merge works in the opposite direction. - -The first bounded profile must qualify these native controls together: - -- Explicit runtime environment; only the selected credentials enter native code. - Deny their variable names to sandboxed commands. Keep secret and history roots - outside the workspace, with both sandbox read/write denies and native Read/Edit - denies. Check workspace symlinks as well as direct paths. -- Empty user/project/local setting sources, fixed native tool inventory, explicit - outside-workspace read restrictions and no bypass permission mode. Operator - policy remains relevant; absence of project settings is not absence of policy. -- Sandbox enabled with `failIfUnavailable`, no unsandboxed fallback, no excluded - commands and no weaker nested/network isolation. Verify the actual seccomp - helper and socket restrictions; a warning-only dependency check is insufficient. -- Separate persistent workspace and protected native history. Check history before - resume, preserve the native Session identity and fail before new work when - required history is absent. An Environment ID is not a backup. - -These are supported native configuration surfaces, not completed production -isolation. The source review used the pinned `sdk.d.ts` and shipped native binary -(SHA-256 `25e44883f54419569a3d739f38cbbdaebe83b09895da0f343e1b003710a4775b`). -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.RunEnvironmentInput` currently owns a connection through preparation -and one Run, then releases it. That is not an idle file owner. Existing durable -connection generations fence observations; they do not revoke an old process or -an already-dispatched file write. - -Determine prerequisites for each public operation under the -[Core/Runtime rules](environments.md#ownership-and-placement-decision). -Environment metadata retrieval already reads durable resources without preparing -execution. Files.list needs live path/size metadata from the authorized workspace; -the private bounded byte reader alone does not implement that route. Reuse native -directory/metadata access and existing lifecycle interfaces through a thin adapter. -Bound a live read to its exact authorized Environment/workspace context, including -when idle, and retain permission, path-isolation and unavailable-runtime checks. -Do not require a complete write, replacement or retirement mechanism for this read. - -For file mutations and owner replacement, reject superseded ownership, including -at the executor. Lease loss stops new mutations and closes the transport; uncertain -effects remain unknown rather than being replayed. A replacement socket alone never -authorizes overlap. Define capacity and release for the lifetime actually used by -the operation; a permanent idle owner is not a universal prerequisite. Releasing -transient credentials must not delete caller-owned files or required native history. - -The tracked exact-pin raw-runner hook now publishes its stock-built manager; -private Files/execution/cancel/history composition is qualified. The injectable -in-process route can drop notifications on saturation, while the maintained raw -socket client's consumer queue is unbounded. The optional private harness artifact -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 (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 - -Start with synthetic credential/file/socket canaries using the pinned native -tools. Stop on disclosure, bypass or fallback; never widen access to obtain a -passing result. Then use a real provider for native command/file effects, fresh -process continuation of the same workspace/history, cancellation with separately -observed process/effect cessation, and missing-history safe failure. This private -prerequisite does not establish public Session preparation, Files or deployment. - -The temporary `mx` placement qualified the pinned native controls and real -workspace/history continuation under a locked runtime identity and explicit -mount/PID boundary. It is not a production placement: CPU/memory/pids limits, -disk quotas, provider-only trusted-harness egress and concurrent tenants remain -unqualified. Default Docker and the tested gVisor version failed the strict native -sandbox prerequisite; do not reuse either unchanged as a supported placement. - -The private typed Claude factory profile composes those controls in the existing -bridge, retaining `none` behavior. Its separate acceptance must use that actual -factory and bridge; the earlier direct native proof alone is insufficient. -Native command/file observations, public preparation and an idle Files owner -are later independently accepted slices. -Use the same fixed SDK/raw HTTP and real execution acceptance for both public -engines before claiming the complete milestone. - -`NATIVE-COMMAND-OUTPUT-001` remains a material Codex retained-output gap. The user -has deferred it from the current principal-workflow milestone acceptance; this does not establish complete output fidelity. The recorded failed real run -is not fixed by a later gated success. No production-ready maintained remedy was -verified in the checked upstream sources; do not fabricate output, repair model -prose or silently adopt a native fork. Reassess that dependency alongside other material security, state and data-loss issues. +The earlier Codex placement assessment recorded `NATIVE-COMMAND-OUTPUT-001`, +a retained-command-output gap. That failed run is historical evidence, not a +current installation prerequisite or a claim that subsequent implementations +fixed output fidelity. Current behavior must be qualified through the shared +Runtime path; do not fabricate missing output. diff --git a/deploy/install/node_spec.py b/deploy/install/node_spec.py index 2165e20cb..a74b79cfe 100644 --- a/deploy/install/node_spec.py +++ b/deploy/install/node_spec.py @@ -25,17 +25,47 @@ def release(manifest): "firmware_sha256": manifest["microsandbox"]["firmware_sha256"]} -def digest(provider, specification): +# BEGIN GENERATED DEPLOYMENT CONTRACT +# Generated from sandbox/deployment_contract.go; do not edit. +_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"disk\":false,\"runtime\":true},\"e2b\":{\"disk\":false,\"runtime\":false},\"microsandbox\":{\"disk\":true,\"runtime\":true}},\"minimum_disk\":1024}") +# END GENERATED DEPLOYMENT CONTRACT + + +def canonical_spec(provider, specification, validate=True): + rules = _CONTRACT["providers"][provider] + if validate and (not isinstance(specification, dict) or set(specification) != ({"resources", "runtime"} if rules["runtime"] else {"resources"})): + raise ValueError("Invalid specification fields") resources = specification["resources"] - ordered = {"cpus": resources["cpus"], "memory_mib": resources["memory_mib"]} - for field in ("root_disk_mib", "environment_disk_mib"): - if resources.get(field): - ordered[field] = resources[field] - runtime = specification["runtime"] - ordered_runtime = {key: runtime[key] for key in ("source_commit", "image_id", "image_manifest_digest", - "microsandbox_ref", "runtime_sha256", "firmware_sha256")} - raw = json.dumps({"provider": provider, "resources": ordered, "runtime": ordered_runtime}, - separators=(",", ":"), ensure_ascii=False).encode() + if validate and (not isinstance(resources, dict) or set(resources) - {rule["name"] for rule in _CONTRACT["resources"]}): + raise ValueError("Invalid resource fields") + ordered = {} + for rule in _CONTRACT["resources"]: + name = rule["name"] + value = resources.get(name, 0) if rule["omit_zero"] else resources[name] + minimum, maximum = rule["min"], rule["max"] + if rule["omit_zero"]: + minimum, maximum = (_CONTRACT["minimum_disk"], maximum) if rules["disk"] else (0, 0) + if validate and (type(value) is not int or not minimum <= value <= maximum): + raise ValueError("Invalid resource value") + if value or not rule["omit_zero"]: + ordered[name] = value + result = {"provider": provider, "resources": ordered} + if rules["runtime"]: + runtime = specification["runtime"] + if validate and (not isinstance(runtime, dict) or set(runtime) != {rule["name"] for rule in _CONTRACT["runtime"]}): + raise ValueError("Invalid release fields") + ordered_runtime = {} + for rule in _CONTRACT["runtime"]: + value = runtime[rule["name"]] + if validate and (not isinstance(value, str) or not re.fullmatch(rule["pattern"], value)): + raise ValueError("Invalid release identity") + ordered_runtime[rule["name"]] = value + result["runtime"] = ordered_runtime + return result + + +def digest(provider, specification): + raw = json.dumps(canonical_spec(provider, specification, validate=False), separators=(",", ":"), ensure_ascii=False).encode() return hashlib.sha256(raw).hexdigest() @@ -51,26 +81,7 @@ def validate(data, args): or type(data["max_active"]) is not int or type(data["max_retained"]) is not int or not 1 <= data["max_active"] <= data["max_retained"] <= 1000000): raise ValueError() - resources = spec["resources"] - if (not {"cpus", "memory_mib"}.issubset(resources) - or set(resources) - {"cpus", "memory_mib", "root_disk_mib", "environment_disk_mib"} - or any(type(value) is not int for value in resources.values()) - or not 1 <= resources["cpus"] <= 255 or not 512 <= resources["memory_mib"] <= 1048576): - raise ValueError() - for field in ("root_disk_mib", "environment_disk_mib"): - if provider == "microsandbox": - if not 1024 <= resources.get(field, 0) <= 4294967295: - raise ValueError() - elif resources.get(field, 0) != 0: - raise ValueError() - runtime = spec["runtime"] - patterns = {"source_commit": r"[0-9a-f]{40}", "image_id": r"sha256:[0-9a-f]{64}", - "image_manifest_digest": r"sha256:[0-9a-f]{64}", - "microsandbox_ref": r"oac-runtime@sha256:[0-9a-f]{64}", - "runtime_sha256": r"[0-9a-f]{64}", "firmware_sha256": r"[0-9a-f]{64}"} - if set(runtime) != set(patterns) or any(not isinstance(runtime[key], str) or not re.fullmatch(pattern, runtime[key]) - for key, pattern in patterns.items()): - raise ValueError() + canonical_spec(provider, spec) if data["specification_digest"] != digest(provider, spec): raise ValueError() except (KeyError, ValueError, TypeError, AttributeError): diff --git a/deploy/install/test_node_spec.py b/deploy/install/test_node_spec.py index 0f139e693..d065b69e0 100644 --- a/deploy/install/test_node_spec.py +++ b/deploy/install/test_node_spec.py @@ -3,6 +3,7 @@ import copy import io import json +from pathlib import Path import unittest import urllib.error from unittest import mock @@ -110,5 +111,19 @@ def test_digest_is_independent_of_response_object_key_order(self): self.assertEqual(node_spec.digest("docker", reordered), node_spec.digest("docker", self.spec)) +class SharedDeploymentContractTests(unittest.TestCase): + def test_shared_acceptance_and_canonical_bytes(self): + path = Path(__file__).resolve().parents[2] / "services/agents-api/internal/sandbox/testdata/deployment-contract.json" + for fixture in json.loads(path.read_text()): + with self.subTest(name=fixture["name"]): + if not fixture["valid"]: + with self.assertRaises((KeyError, ValueError, TypeError)): + node_spec.canonical_spec(fixture["provider"], fixture["specification"]) + continue + canonical = json.dumps(node_spec.canonical_spec(fixture["provider"], fixture["specification"]), separators=(",", ":"), ensure_ascii=False) + self.assertEqual(canonical, fixture["canonical"]) + self.assertEqual(node_spec.digest(fixture["provider"], fixture["specification"]), fixture["digest"]) + + if __name__ == "__main__": unittest.main() diff --git a/docs/api/README.md b/docs/api/README.md index 18c826a40..41f9a47f1 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -8,7 +8,7 @@ public release content. | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`, `harness_config`, Session `installation`) | [Agents API guide](public-agent-api.md) | | `/core/v1` | Core Web's server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](web-management.md), [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core issues for hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in diff --git a/docs/architecture.md b/docs/architecture.md index 5b5ad782f..dd962dbee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ operating system or provider name. See | Part | Responsibility | Connects through | Current implementations | Add one | | --- | --- | --- | --- | --- | | Sandbox Provider | Creates, bootstraps, renews and reclaims the outer Environment | `SandboxProvider` interface | Docker, microsandbox, E2B, sandbox nodes | [Sandbox Provider guide](sandbox-provider.md) | -| Runtime | Prepares Skills, MCP and files, runs executors, owns local cleanup | Core–Runtime protocol over `/api/v1` | `oac-daemon`, managed or self-hosted on Linux, macOS and Windows | [Core–Runtime protocol](runtime-protocol.md) | +| Runtime | Prepares Skills, MCP and files, runs executors, owns local cleanup | Core–Runtime protocol over `/api/v1` | `oac-daemon`: managed Linux; self-hosted Linux, macOS and Windows | [Core–Runtime protocol](runtime-protocol.md) | | Harness | Runs the native model and tool loop | Harness adapter (`Executor` and `Turn`) | Codex, Claude Code, MiniMax Code | [Harness onboarding](../contracts/agents-api/harness-onboarding.md) | | Model Provider | Serves inference for the harness | Responses, Anthropic or Chat Completions protocol | Any endpoint speaking one of those protocols | [Model execution](../contracts/agents-api/model-execution.md) | @@ -68,9 +68,10 @@ For a Core-managed (`openai_hosted`) Session: 1. The application creates a Session through the Agents API. 2. Core asks the Sandbox Provider for an Environment. -3. The provider boots the Runtime daemon inside it. +3. The provider starts the Runtime using the [bootstrap contract](runtime-bootstrap.md). 4. The daemon dials into Core and advertises its capabilities. -5. Core prepares the Environment: Skills, MCP declarations and initial files. +5. Core sends the preparation request; Runtime prepares Skills, MCP declarations + and initial files inside the Environment. 6. The application sends input. 7. Core prepares and starts execution on the daemon. 8. The daemon's harness adapter starts a native Turn. diff --git a/docs/configuration.md b/docs/configuration.md index af964cf35..94634da3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -174,9 +174,9 @@ Same-team E2B template, resource and key changes apply online through the Core A new allocations use the new generation, while existing sandboxes retain their original specification. Omit the key to preserve it; explicitly submitting a key, even the same value, verifies the replacement and advances the generation. Keep the -old key valid until the update succeeds. Initial setup requires a team-owned template; -a legacy public-template configuration or an already revoked old key requires reset -when Core cannot verify the committed ownership anchor. +old key valid until the update succeeds. Initial setup requires a team-owned template. +If Core cannot verify the committed ownership anchor, reset the deployment before +setting it up again. Docker and microsandbox size/Runtime edits advance the target generation online. They require neither zero held resources nor node retirement or reenrollment. @@ -187,9 +187,8 @@ preparing or has failed. Target rollout and serving readiness are separate facts Use **System** → **Sandbox backend** → **Change resources** to edit the target; **Nodes** owns node management and readiness. -Current Runtime generation coexistence and ownership-scoped garbage collection -remain supported. They do not upgrade the installed node program or convert an old -installation. History stays, and existing Sessions never move between providers. +Runtime generation coexistence and ownership-scoped garbage collection operate +within the installed node release. Existing Sessions retain their placement and history. See the [nodes guide](getting-started/nodes.md#change-the-sandbox-backend-or-size), [operator reference](../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) and [deployment contract](../contracts/agents-api/sandbox-deployment.md). diff --git a/docs/development.md b/docs/development.md index 9bc024ef8..9a744d834 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,6 +86,7 @@ site; `pnpm dev:docs` starts its development server. | `services/agents-api/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](runtime-protocol.md) | | `services/agents-api/internal/engine` | Pure qualification of harness operations and placements | [Harness onboarding](../contracts/agents-api/harness-onboarding.md) | | `internal/agentdaemon/proto` and `gateway` | Shared wire types, validators and authenticated Runtime connections | [Runtime protocol](runtime-protocol.md) | +| `internal/runtimebootstrap` | Provider-to-Runtime startup input | [Runtime bootstrap](runtime-bootstrap.md) | | `apps/parsar-daemon/internal/dispatch` | Runtime preparation, Executor reuse, Turn and cleanup ownership | [Harness lifecycle](../contracts/agents-api/harness-onboarding.md#required-adapter-interfaces) | | `apps/parsar-daemon/internal/agent` | Native harness adapters | [Native references](../contracts/agents-api/harness-onboarding.md#native-references) | | `services/agents-api/internal/sandbox` | Provider interfaces and managed compute lifecycle | [Provider onboarding](sandbox-provider.md) | @@ -109,6 +110,7 @@ only helps you pick the right one. | --- | --- | --- | | Harness adapter | A native agent engine behind the Runtime | [Harness onboarding](../contracts/agents-api/harness-onboarding.md) | | Sandbox Provider | Outer compute that creates and reclaims Environments | [Sandbox Provider guide](sandbox-provider.md) | +| Runtime bootstrap | Starting a managed Runtime with its connection identity | [Runtime bootstrap](runtime-bootstrap.md) | | Core–Runtime protocol | A message, receipt or lifecycle rule between Core and the daemon | [Core–Runtime protocol](runtime-protocol.md) | | Public API operation | A `/v1`, `/core/v1` or `/api/v1` route | [API index](api/README.md) and [contracts](../contracts/agents-api/README.md) | | Environment capability | Skills, Plugins, MCP or `packages.system` preparation | [Environments](../contracts/agents-api/environments.md#runtime-capability-preparation) | diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index bea43a03b..123c12b01 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -265,10 +265,8 @@ describes reset and API behavior. Same-team E2B edits have a separate online Cor API path and do not involve node installation. -## Historical node installations +## Installation scope -An older node installation is unsupported by the current installer. Retained state -under the old `parsar-node` names is a refusal condition, not an invitation to -convert or delete it. Preserve the old installation, Runtime resources and history; -provision a fresh node separately through **Add node**. The current installer does -not adopt historical resources or remove another installation's state. +Nodes and Core must come from the same distribution. Provision nodes through +**Add node**; the installer never adopts or removes another installation's +resources. See the [installation version policy](operations.md#installation-version-policy). diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 2ad9a1273..57cea2197 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -173,12 +173,8 @@ and mutating `oac` commands use the same installation lock, including during rep and interrupted apply recovery. If another command owns it, retry after it finishes. Never remove or replace `.oac.lock` to bypass a busy installation. -Historical installations and removed CLI or machine-protocol variants have no -migration procedure. Preserve their files, database, Runtime resources and Session -history; use a fresh installation and separately provision its nodes. Reinstallation -does not authorize deleting the previous installation's data. Retired process -settings remain rejected; use the current -[configuration reference](../configuration.md). +Reinstallation never deletes another installation's files, database, Runtime +resources or Session history. ### Machine connections diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index e1d492834..b039dbded 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -119,10 +119,8 @@ through Core's loopback port. This is not required for one-command onboarding. See the [credential contract](../../contracts/agents-api/environment-executor-credentials.md) for those routes and uncertain-response handling. -## Historical executor installations +## Installation scope -The native installer supports its current version only. It does not adopt or -upgrade an older daemon or container installation. Preserve old files, containers -and native history; use a fresh Runtime home and Session when moving versions. -Historical container-installer instructions and qualification records describe -those earlier artifacts, not acceptance of the current native daemon. +The [native installation guide](../self-hosted-native.md#add-harnesses-and-operate-the-installation) +owns supported installation operations and component validation. Stopping a Runtime +or deleting a Session never removes the user's files or native history. diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md new file mode 100644 index 000000000..c448f9b96 --- /dev/null +++ b/docs/runtime-bootstrap.md @@ -0,0 +1,56 @@ +# Runtime bootstrap + +This document owns the Provider-to-Runtime startup boundary. The input type and +validator live in [runtimebootstrap](../internal/runtimebootstrap/bootstrap.go). +Providers must not read or write Runtime's private authentication store. + +## Launch input + +Deliver one JSON object in a regular file accessible only to the Runtime account +and trusted provisioning processes (0600 on managed Linux). Pass its absolute path: + +```sh +oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json +``` + +| Field | Meaning | +| --- | --- | +| `version` | Exact bootstrap version declared by `runtimebootstrap.Version` | +| `core_url` | HTTP(S) machine API base ending in `/api/v1`, without credentials, query or fragment | +| `device_id` | Canonical nonzero UUID for the daemon identity issued by Core | +| `credential` | Nonempty daemon credential issued by Core, without whitespace or NUL | + +The decoder rejects unknown, duplicate, missing and case-aliased fields, other +versions and documents exceeding `runtimebootstrap.MaxBytes`. Errors exclude +submitted values. Go providers use `Bootstrap.RuntimeConnection()`; SDK helpers +forward the serialized object without defining their own authentication format. + +The file is the sole authentication input for this launch. It cannot be combined +with pairing or self-hosted enrollment flags. Credentials never go in command +arguments, environment variables or receipts. The provider retains the protected +file for process restarts and removes it only with explicit owned-resource cleanup. +Runtime reads it into memory and neither overwrites nor falls back to a private +auth profile. A missing or malformed file fails before connecting. + +## Responsibilities and readiness + +The Provider provisions the account, mounts and workspace, delivers this input, +sets the existing Runtime resource and Environment binding settings, and starts +the daemon as the unprivileged Runtime account. Docker supplies a file in its +owned home volume; microsandbox and E2B deliver it before launching the same +command. These are delivery mechanisms, not different bootstrap protocols. + +Runtime validates the input and owns authentication and connection establishment. +A successful process launch proves only handoff; authenticated connection, +capability preparation and execution readiness remain separate observations under +the [Core–Runtime protocol](runtime-protocol.md). + +Self-hosted enrollment exchanges its executor credential for a daemon identity +through the machine API; it is a different source of authority, not a managed +bootstrap-file fallback. Both paths enter the same Runtime execution loop. + +## Verification + +`go test ./internal/runtimebootstrap ./apps/parsar-daemon/internal/cli` covers the +input contract, credential-source exclusivity and restart behavior. Provider tests +verify delivery and permissions without relying on private Runtime storage. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index c25ca0ae5..1a572ad0b 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -139,11 +139,9 @@ that already owns the Executor. Connection shutdown owns transport-loss cleanup. Preserve the ten-second settlement wait and separate five-second receipt send budget; timeout is not proof of quiescence. The observed cancellation outcome retains native identity, Usage and output without fabricating missing evidence. -Codex native Turn interruption can leave background terminals alive. Its adapter -uses the exact thread-owned terminal list and confirmed per-terminal termination -before settling cancellation, and repeats this cleanup during Executor close. -The bulk clean acknowledgement does not prove termination. Failed cleanup keeps -the native owner available for a later close attempt. +Adapters must include owned background work in settlement and retain the exact +native cleanup target after failure. Native termination mechanisms belong to the +adapter; a bulk cleanup acknowledgement alone cannot establish quiescence. Private preparation controls reserve a per-Turn admission, not a new Executor. They carry an explicit Session identity and immutable configuration without model @@ -201,6 +199,14 @@ function-result acknowledgements use the Run ID. All application decision receipts additionally match the delivery ID. A reply without the required correlation cannot establish acceptance. +User-choice decisions carry `question_answers` with an explicit `question_id` +and an `answers` array for each provided answer. The IDs must belong to the emitted +questions and cannot repeat. Question order and display headers do not identify +answers; omitted questions remain unanswered and an empty array is an explicit +non-answer. Cancellation carries `cancelled: true` without answers. Shared +validation rejects other shapes before native submission. Adapters translate the +identified values into their native response without changing question identity. + ## Message families Use the linked source definitions for required fields, validators, limits and @@ -302,13 +308,10 @@ Workspace reads may request the private `workspace_read_only` preparation profil through the existing preparation factory and verified `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model/MCP credentials, native Session continuation and model/tool input are excluded. -The Codex adapter creates temporary local state, reuses its native connection and -directory transport, and rejects Start. Its child inherits only process/transport -essentials. The private native read mode excludes system, managed, user and project -execution configuration and plugin startup while preserving native security -requirements. Ordinary execution keeps its stable state and configuration. -Reject the legacy mixed managed-config profile for reads rather than discarding -its enforced constraints together with execution settings. +A read-only owner rejects Start and excludes model input, execution configuration +and plugin startup. A Runtime may satisfy this contract through its bound local +filesystem implementation; starting a native Harness process is not required. +Adapters that use native filesystem controls retain their own initialization rules. For this read profile, `released` is published only after local Close succeeds; cleanup errors retain ownership and report `cleanup_unconfirmed`. A failed factory must return its resource with the error if cleanup remains unconfirmed; wrappers @@ -443,10 +446,9 @@ 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. 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. +private interface. The Runtime binds access to the frozen local workspace, +prevents path escape, bounds enumeration and requires a complete validated result. +Filesystem readiness 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. @@ -461,11 +463,6 @@ pending operation IDs cannot start another read; this control does not promise d idempotency or result recovery. Preparation/Run ownership, public path authorization, 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; `environments.status` and its Session Environment-event snapshot commit together. @@ -505,3 +502,12 @@ then run its own native acceptance for the capabilities it advertises. A passing controlled-adapter test establishes the transport contract, not native Harness behavior, OS support, provider authentication or sandbox isolation. Update the shared types, this guide and the contract checks together when semantics change. + +The reusable [Harness text assertions](../apps/parsar-daemon/internal/agent/contracttest/text.go) +accept any prepared Executor and a small fixture supplying deterministic normal, +active and steering inputs. They check independent Turn streams, native owner and +history continuity, durable write/application receipts, stale cancellation and +healthy continuation after cancellation. The Claude Go adapter runs them against +its controlled native subprocess fixture. New adapters can call the same assertions; +no optional feature is implied. Native failures, uncertain cleanup and exact-history +recovery still require the adapter's fault and real-provider acceptance tests. diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 42396bad3..52b1cfc40 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -234,6 +234,10 @@ Node tests separately exercise disconnect/reconnect fencing and cleanup after a lost create response. Provider helper protocols and the node protocol require an exact version match and reject mismatches; do not add fallback decoders or old binary migration. Direct in-process interfaces have no independent wire version. +Node generation management is an explicit current hello capability, not another +wire version. Fixed-configuration manual nodes use the same protocol and only +serve their enrolled deployment generation. See the +[node contract](../contracts/agents-api/node-generation-protocol.md). Run `make check` with its dedicated database before completion. Retain native acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index dc618f530..80d23ec1c 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -22,7 +22,7 @@ The console endpoint and the public application endpoint serve different purpose even if they share a host. Use the [installation guide](../getting-started/install.md) for deployment and the -[operations guide](../getting-started/operations.md) for storage, upgrades and node +[operations guide](../getting-started/operations.md) for storage, same-release repair and node management. A Core, Web and PostgreSQL installation may have zero execution nodes. Opening the console neither allocates compute nor invokes a model. Deployment sandbox management selects E2B, Docker or microsandbox independently of an diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go index 42b9b69ea..02b71c726 100644 --- a/internal/agentdaemon/device/state.go +++ b/internal/agentdaemon/device/state.go @@ -60,15 +60,15 @@ type HeartbeatStatus struct { // KindCapabilities mirrors the daemon heartbeat capability // shape after gateway-level normalization. Persistence stays separate from wire protocol structs. type KindCapabilities struct { - SubagentObservations bool `json:"subagent_observations,omitempty"` - Streaming bool `json:"streaming,omitempty"` - Permissions bool `json:"permissions,omitempty"` - Usage bool `json:"usage,omitempty"` - Resume bool `json:"resume,omitempty"` - NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` - Steering bool `json:"steering,omitempty"` - MessageItems bool `json:"message_items,omitempty"` - ToolItems bool `json:"tool_items,omitempty"` + SubagentObservations bool `json:"subagent_observations,omitempty"` + Streaming bool `json:"streaming,omitempty"` + Permissions bool `json:"permissions,omitempty"` + Usage bool `json:"usage,omitempty"` + Resume bool `json:"resume,omitempty"` + NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` + Steering bool `json:"steering,omitempty"` + MessageItems bool `json:"message_items,omitempty"` + ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` diff --git a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go index d0fcb62ae..c9045ffdc 100644 --- a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go +++ b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go @@ -87,7 +87,7 @@ func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { turn := &mcpBearerTurn{} turns = append(turns, turn) runID := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "codex", ConversationID: "mcp-bearer-acceptance", RunID: runID, Input: proto.TextInput(prompt), AgentStateKey: "mcp-bearer-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveTools: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "MiniMax-M3"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} + request := proto.PromptRequestPayload{AgentKind: "codex", ConversationID: "mcp-bearer-acceptance", RunID: runID, Input: proto.TextInput(prompt), AgentStateKey: "mcp-bearer-acceptance", AgentSessionID: resume, StrictResume: true, ReleaseOnCompletion: true, ObserveMessages: true, ObserveToolObservations: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "MiniMax-M3"}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} sub, err := peer.SubscribeDurable(runID) if err != nil { t.Fatal("cannot subscribe before real daemon dispatch") diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index f74b7e5fd..4e742bbe0 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -510,16 +510,16 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK Available: info.Available, Version: info.Version, Capabilities: device.KindCapabilities{ - Streaming: info.Capabilities.Streaming, - Permissions: info.Capabilities.Permissions, - Usage: info.Capabilities.Usage, - Resume: info.Capabilities.Resume, - Steering: info.Capabilities.Steering, - DurableTurns: info.Capabilities.DurableTurns, - DurableInputReceipts: info.Capabilities.DurableInputReceipts, - NativeSessionRecovery: info.Capabilities.NativeSessionRecovery, - MessageItems: info.Capabilities.MessageItems, - ToolItems: info.Capabilities.ToolItems, + Streaming: info.Capabilities.Streaming, + Permissions: info.Capabilities.Permissions, + Usage: info.Capabilities.Usage, + Resume: info.Capabilities.Resume, + Steering: info.Capabilities.Steering, + DurableTurns: info.Capabilities.DurableTurns, + DurableInputReceipts: info.Capabilities.DurableInputReceipts, + NativeSessionRecovery: info.Capabilities.NativeSessionRecovery, + MessageItems: info.Capabilities.MessageItems, + ToolObservations: info.Capabilities.ToolObservations, EnvironmentNone: info.Capabilities.EnvironmentNone, LocalEnvironment: info.Capabilities.LocalEnvironment, diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go index 74f7b4ad6..c70dde3d3 100644 --- a/internal/agentdaemon/gateway/session_test.go +++ b/internal/agentdaemon/gateway/session_test.go @@ -401,7 +401,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, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true}, + Capabilities: proto.AgentKindCapabilities{MCPHTTPTools: true, Steering: true, MessageItems: true, ToolObservations: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true}, }, }, }) @@ -427,7 +427,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.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.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/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index ed0bedb55..9ba27830c 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -1,7 +1,5 @@ package proto -import "encoding/json" - // This package lives at the repo-root module so both the server-side // gateway/connector AND apps/parsar-daemon can import it. That rules out // importing server/internal/... (Go's internal-package rule), so wire @@ -94,8 +92,6 @@ type ThinkingPayload struct { // when the agent is about to call the tool, "after" when the result // is back. type ToolCallPayload struct { - // NativeItem is an opt-in engine snapshot for execution-service projection, not a public Item. - NativeItem json.RawMessage `json:"native_item,omitempty"` Observation *ToolObservation `json:"observation,omitempty"` ID string `json:"id"` Name string `json:"name"` @@ -224,16 +220,16 @@ const ( // cancellation belong to the daemon connector itself; these bits are // the engine-specific surface the UI uses for filtering and copy. type AgentKindCapabilities struct { - SubagentObservations bool `json:"subagent_observations,omitempty"` - Streaming bool `json:"streaming,omitempty"` - Permissions bool `json:"permissions,omitempty"` - Usage bool `json:"usage,omitempty"` - Resume bool `json:"resume,omitempty"` - NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` - WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` - Steering bool `json:"steering,omitempty"` - MessageItems bool `json:"message_items,omitempty"` - ToolItems bool `json:"tool_items,omitempty"` + SubagentObservations bool `json:"subagent_observations,omitempty"` + Streaming bool `json:"streaming,omitempty"` + Permissions bool `json:"permissions,omitempty"` + Usage bool `json:"usage,omitempty"` + Resume bool `json:"resume,omitempty"` + NativeSessionRecovery bool `json:"native_session_recovery,omitempty"` + WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` + Steering bool `json:"steering,omitempty"` + MessageItems bool `json:"message_items,omitempty"` + ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 3263f5afa..3a99400f9 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -82,17 +82,17 @@ type PromptRequestPayload struct { AgentStateKey string `json:"agent_state_key,omitempty"` WorkspaceAuthoring bool `json:"workspace_authoring,omitempty"` // ReleaseOnCompletion closes the native writer before acknowledging Done. - ReleaseOnCompletion bool `json:"release_on_completion,omitempty"` - StrictResume bool `json:"strict_resume,omitempty"` - RequireExistingNativeSession bool `json:"require_existing_native_session,omitempty"` - ObserveMessages bool `json:"observe_messages,omitempty"` - ObserveTools bool `json:"observe_tools,omitempty"` - ObserveToolObservations bool `json:"observe_tool_observations,omitempty"` - ObserveSubagentIdentities bool `json:"observe_subagent_identities,omitempty"` - FunctionTools []FunctionTool `json:"function_tools,omitempty"` - ToolSearch bool `json:"tool_search,omitempty"` - DisableExecutionEnvironment bool `json:"disable_execution_environment,omitempty"` - DisableSubagents bool `json:"disable_subagents,omitempty"` + ReleaseOnCompletion bool `json:"release_on_completion,omitempty"` + StrictResume bool `json:"strict_resume,omitempty"` + RequireExistingNativeSession bool `json:"require_existing_native_session,omitempty"` + ObserveMessages bool `json:"observe_messages,omitempty"` + + ObserveToolObservations bool `json:"observe_tool_observations,omitempty"` + ObserveSubagentIdentities bool `json:"observe_subagent_identities,omitempty"` + FunctionTools []FunctionTool `json:"function_tools,omitempty"` + ToolSearch bool `json:"tool_search,omitempty"` + DisableExecutionEnvironment bool `json:"disable_execution_environment,omitempty"` + DisableSubagents bool `json:"disable_subagents,omitempty"` } // PromptCancelPayload optionally requests an application receipt; identity is on Envelope.ID. @@ -110,32 +110,19 @@ type PermissionDecisionPayload struct { UpdatedInput map[string]any `json:"updated_input,omitempty"` } -// PromptForUserChoiceQuestionAnswer carries one (question, answer) -// pair from a multi-question submit. QuestionID is the canonical key; -// Header and Answer remain as compatibility fields for older peers. +// PromptForUserChoiceQuestionAnswer binds answer values to one emitted question ID. +// Headers and array positions never identify a question. type PromptForUserChoiceQuestionAnswer struct { - QuestionID string `json:"question_id,omitempty"` - Answers []string `json:"answers,omitempty"` - Header string `json:"header,omitempty"` - Answer string `json:"answer,omitempty"` + QuestionID string `json:"question_id"` + Answers []string `json:"answers"` } -// PromptForUserChoiceDecisionPayload carries the human's pick. The -// daemon turns this into a tool_result JSON the agent's stdin -// consumes. -// -// - QuestionAnswers carries one entry per question, keyed by stable -// QuestionID with the selected values preserved as an array. -// - Answers length == 1 for single-select; length N for multi-select. -// Legacy single-question callers may still write this; the daemon -// treats it as "all answers belong to question 0". -// - Cancelled=true marks a non-answer (timeout, /cancel). Reason is -// a short machine tag (e.g. "timeout"); the daemon converts it -// into a tool_result message the LLM understands. +// PromptForUserChoiceDecisionPayload carries either explicitly identified answers +// or cancellation. Omitted questions remain unanswered; adapters never reassign +// answers by position or display text. Native choice validation stays in the adapter. type PromptForUserChoiceDecisionPayload struct { DeliveryID string `json:"delivery_id"` QuestionAnswers []PromptForUserChoiceQuestionAnswer `json:"question_answers,omitempty"` - Answers []string `json:"answers,omitempty"` Cancelled bool `json:"cancelled,omitempty"` Reason string `json:"reason,omitempty"` } diff --git a/internal/agentdaemon/proto/subagents.go b/internal/agentdaemon/proto/subagents.go index 63559171b..06072c835 100644 --- a/internal/agentdaemon/proto/subagents.go +++ b/internal/agentdaemon/proto/subagents.go @@ -50,7 +50,7 @@ type SubagentTurnPayload struct { // SubagentItemPayload reuses ordinary message/tool observations. Payload is a // complete snapshot, not a delta. Position is the native order within its Turn. -// NativeItem and engine-specific payloads are not admitted by this contract. +// Engine-specific payloads are not admitted by this contract. type SubagentItemPayload struct { NativeID string `json:"native_id"` TurnID string `json:"turn_id"` diff --git a/internal/agentdaemon/proto/tool_call.go b/internal/agentdaemon/proto/tool_call.go new file mode 100644 index 000000000..721cd0e8c --- /dev/null +++ b/internal/agentdaemon/proto/tool_call.go @@ -0,0 +1,18 @@ +package proto + +import ( + "bytes" + "encoding/json" +) + +func (p *ToolCallPayload) UnmarshalJSON(raw []byte) error { + type toolCall ToolCallPayload + var value toolCall + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return err + } + *p = ToolCallPayload(value) + return nil +} diff --git a/internal/agentdaemon/proto/user_choice.go b/internal/agentdaemon/proto/user_choice.go new file mode 100644 index 000000000..3c59f701f --- /dev/null +++ b/internal/agentdaemon/proto/user_choice.go @@ -0,0 +1,67 @@ +package proto + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// UnmarshalJSON rejects fields outside the current decision contract, including +// unrecognized fields nested inside question_answers. Do not silently discard an +// answer from a peer using a different wire shape. +func (p *PromptForUserChoiceDecisionPayload) UnmarshalJSON(raw []byte) error { + if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' { + return errors.New("user-choice decision must be an object") + } + type decision PromptForUserChoiceDecisionPayload + var value decision + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return errors.New("invalid user-choice decision") + } + decoded := PromptForUserChoiceDecisionPayload(value) + if err := decoded.Validate(); err != nil { + return err + } + *p = decoded + return nil +} + +// Validate checks answer identity independently of delivery correlation and +// native question constraints. An empty answer array is an explicit non-answer. +func (p PromptForUserChoiceDecisionPayload) Validate() error { + if p.Cancelled && len(p.QuestionAnswers) != 0 { + return errors.New("cancelled user-choice decision cannot contain answers") + } + seen := make(map[string]bool, len(p.QuestionAnswers)) + for _, answer := range p.QuestionAnswers { + if strings.TrimSpace(answer.QuestionID) == "" || seen[answer.QuestionID] || answer.Answers == nil { + return errors.New("user-choice answers require unique question IDs and answer arrays") + } + seen[answer.QuestionID] = true + } + return nil +} + +// AnswersFor binds a decision to the exact emitted question identities. It never +// derives identity from a header, answer text or array position. Call it before +// consuming the pending interaction or submitting a native response. +func (p PromptForUserChoiceDecisionPayload) AnswersFor(questionIDs []string) (map[string][]string, error) { + if err := p.Validate(); err != nil { + return nil, err + } + known := make(map[string]bool, len(questionIDs)) + for _, id := range questionIDs { + known[id] = true + } + answers := make(map[string][]string, len(p.QuestionAnswers)) + for _, answer := range p.QuestionAnswers { + if !known[answer.QuestionID] { + return nil, errors.New("user-choice answer refers to an unknown question") + } + answers[answer.QuestionID] = answer.Answers + } + return answers, nil +} diff --git a/internal/agentdaemon/proto/user_choice_test.go b/internal/agentdaemon/proto/user_choice_test.go new file mode 100644 index 000000000..74bfc55cf --- /dev/null +++ b/internal/agentdaemon/proto/user_choice_test.go @@ -0,0 +1,46 @@ +package proto + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestUserChoiceOnlyAcceptsCurrentAnswerShape(t *testing.T) { + for _, raw := range []string{ + `null`, + `{"answers":["yes"]}`, + `{"question_answers":[{"header":"Question","answer":"yes"}]}`, + `{"question_answers":[{"question_id":"q","answers":["yes"],"answer":"no"}]}`, + `{"question_answers":[{"answers":["yes"]}]}`, + `{"question_answers":[{"question_id":"q","answers":null}]}`, + `{"question_answers":[{"question_id":"q","answers":["yes"]},{"question_id":"q","answers":["no"]}]}`, + `{"cancelled":true,"question_answers":[{"question_id":"q","answers":["yes"]}]}`, + } { + var decision PromptForUserChoiceDecisionPayload + if json.Unmarshal([]byte(raw), &decision) == nil { + t.Fatalf("accepted invalid decision: %s", raw) + } + } + var decision PromptForUserChoiceDecisionPayload + if err := json.Unmarshal([]byte(`{"delivery_id":"d","question_answers":[{"question_id":"b","answers":["two,three","four"]},{"question_id":"a","answers":[]}]}`), &decision); err != nil { + t.Fatal(err) + } + answers, err := decision.AnswersFor([]string{"a", "b"}) + if err != nil || !reflect.DeepEqual(answers["b"], []string{"two,three", "four"}) || answers["a"] == nil { + t.Fatalf("answers=%v err=%v", answers, err) + } + if _, err := decision.AnswersFor([]string{"a"}); err == nil { + t.Fatal("accepted a foreign question ID") + } + if _, err := (PromptForUserChoiceDecisionPayload{Cancelled: true}).AnswersFor([]string{"a"}); err != nil { + t.Fatal(err) + } +} + +func TestToolCallRejectsEngineSnapshot(t *testing.T) { + var call ToolCallPayload + if json.Unmarshal([]byte(`{"id":"tool","stage":"after","native_item":{"type":"commandExecution"}}`), &call) == nil { + t.Fatal("accepted engine-specific snapshot in common wire") + } +} diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 9725840e6..bc752f8d0 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -2,7 +2,7 @@ package proto // Version identifies the complete Core–Runtime wire contract. Change it when // removing or changing a payload or its semantics; deploy both endpoints together. -const Version = "0.8.0" +const Version = "0.9.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go index 027e98b83..7d8719c93 100644 --- a/internal/agentdaemon/proto/workspace_read_preparation.go +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -8,6 +8,6 @@ func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { r.ConversationID == "" && r.AgentSessionID == "" && r.WorkDir == "" && !r.RequireExistingNativeSession && !r.WorkspaceAuthoring && !r.DisableExecutionEnvironment && len(r.AgentOptions) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && - len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveMessages && !r.ObserveTools && + len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveMessages && !r.ObserveToolObservations && !r.ObserveSubagentIdentities } diff --git a/internal/runtimebootstrap/bootstrap.go b/internal/runtimebootstrap/bootstrap.go new file mode 100644 index 000000000..bab5e7b16 --- /dev/null +++ b/internal/runtimebootstrap/bootstrap.go @@ -0,0 +1,100 @@ +// Package runtimebootstrap owns the Provider-to-Runtime connection input. +// Providers deliver this document as a private file; only Runtime interprets it. +package runtimebootstrap + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/url" + "strings" + "unicode" + + "github.com/google/uuid" +) + +const Version = 1 +const MaxBytes = 16 * 1024 + +var ErrInvalid = errors.New("invalid Runtime bootstrap input") + +// Connection is a current-version launch input, not Runtime's private auth store. +// CoreURL includes the machine API base path. The provider owns delivery and +// file permissions; Runtime owns validation and its authentication representation. +type Connection struct { + Version int `json:"version"` + CoreURL string `json:"core_url"` + DeviceID string `json:"device_id"` + Credential string `json:"credential"` +} + +func (c Connection) Validate() error { + u, err := url.Parse(c.CoreURL) + id, idErr := uuid.Parse(c.DeviceID) + if c.Version != Version || err != nil || (u.Scheme != "https" && u.Scheme != "http") || + u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + u.RawPath != "" || u.Path != "/api/v1" || + strings.ContainsAny(c.CoreURL, "?#") || strings.ContainsFunc(c.CoreURL, unicode.IsSpace) || + idErr != nil || id == uuid.Nil || id.String() != c.DeviceID || + c.Credential == "" || strings.ContainsFunc(c.Credential, func(r rune) bool { return r == 0 || unicode.IsSpace(r) }) { + return ErrInvalid + } + raw, err := json.Marshal(c) + if err != nil || len(raw) > MaxBytes { + return ErrInvalid + } + return nil +} + +func (c Connection) Marshal() ([]byte, error) { + if err := c.Validate(); err != nil { + return nil, err + } + return json.Marshal(c) +} + +// Decode accepts one exact object. Unknown, duplicate, missing and case-aliased +// fields reject instead of introducing alternate spellings of this contract. +func Decode(raw []byte) (Connection, error) { + var c Connection + if len(raw) > MaxBytes { + return c, ErrInvalid + } + d := json.NewDecoder(bytes.NewReader(raw)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return c, ErrInvalid + } + seen := map[string]bool{} + for d.More() { + token, err = d.Token() + key, ok := token.(string) + if err != nil || !ok || seen[key] { + return Connection{}, ErrInvalid + } + seen[key] = true + switch key { + case "version": + err = d.Decode(&c.Version) + case "core_url": + err = d.Decode(&c.CoreURL) + case "device_id": + err = d.Decode(&c.DeviceID) + case "credential": + err = d.Decode(&c.Credential) + default: + return Connection{}, ErrInvalid + } + if err != nil { + return Connection{}, ErrInvalid + } + } + if token, err = d.Token(); err != nil || token != json.Delim('}') { + return Connection{}, ErrInvalid + } + if len(seen) != 4 || d.Decode(new(any)) != io.EOF || c.Validate() != nil { + return Connection{}, ErrInvalid + } + return c, nil +} diff --git a/internal/runtimebootstrap/bootstrap_test.go b/internal/runtimebootstrap/bootstrap_test.go new file mode 100644 index 000000000..132e5659a --- /dev/null +++ b/internal/runtimebootstrap/bootstrap_test.go @@ -0,0 +1,60 @@ +package runtimebootstrap + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestConnectionRoundTrip(t *testing.T) { + input := Connection{Version: Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-credential"} + raw, err := input.Marshal() + if err != nil { + t.Fatal(err) + } + got, err := Decode(raw) + if err != nil || got != input { + t.Fatal("connection changed in transit", err) + } +} + +func TestConnectionRejectsAmbiguousOrForeignInput(t *testing.T) { + good := `{"version":1,"core_url":"https://core.example/api/v1","device_id":"da912024-1543-4242-a2c1-5f4f7ebbc6c7","credential":"test-secret"}` + for name, raw := range map[string]string{ + "null": "null", "array": "[]", "trailing": good + "{}", + "duplicate": strings.Replace(good, `"version":1`, `"version":1,"version":1`, 1), + "unknown": strings.Replace(good, `"version":1`, `"extra":1,"version":1`, 1), + "case": strings.Replace(good, "credential", "Credential", 1), + "old auth": `{"server_url":"https://core.example","runtime_id":"old","runner_credential":"test-secret"}`, + "oversized": strings.Repeat(" ", MaxBytes) + good, + } { + t.Run(name, func(t *testing.T) { + if _, err := Decode([]byte(raw)); err != ErrInvalid { + t.Fatal("accepted invalid input") + } + }) + } + for field, values := range map[string][]any{ + "version": {nil, 0, 2, "1"}, + "core_url": {"http://user:pass@core.example/api/v1", "https://core.example/api/v1?", "https://core.example/api/v1#", "https://core.example", "https://core.example/api/v1/", "https://core.example/api/v1?q=1", ""}, + "device_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, + "credential": {nil, "", "test\nsecret", "test\x00secret"}, + } { + for _, value := range values { + var input map[string]any + _ = json.Unmarshal([]byte(good), &input) + input[field] = value + raw, _ := json.Marshal(input) + if _, err := Decode(raw); err != ErrInvalid { + t.Fatalf("accepted invalid %s", field) + } + } + var input map[string]any + _ = json.Unmarshal([]byte(good), &input) + delete(input, field) + raw, _ := json.Marshal(input) + if _, err := Decode(raw); err != ErrInvalid { + t.Fatalf("accepted missing %s", field) + } + } +} diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index 7219237a7..93cc1fd06 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -27,7 +27,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/runtimefs internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/obs/log services/agents-api \ + internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 9acf00504..dc6479f22 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -171,7 +171,9 @@ or release are refused without rewriting state. ### Manual registration -Manual registration remains available for operator-managed payloads. Build or install +Manual registration remains available for operator-managed payloads. These nodes use +the same current wire version and exact generation routing as installer-managed +nodes; they do not advertise automatic generation preparation. Build or install `oac-node` from the same Core release. On the host, first read `GET /api/v1/sandbox-node/configuration` with the enrollment Bearer token. Build the private provider JSON from its `provider`, `installation_id`, `core_url` (the @@ -280,9 +282,10 @@ selections without this anchor and revoked committed keys require reset (`sandbox_reset_required`); transport or unsettled ownership remains unconfirmed (503). Keep the old key valid until PUT returns 200, then revoke it. A rejected change keeps the current key, generation and resource owners. Docker/microsandbox -resource/Runtime edits still require zero unreleased allocations and pending hosted -Environments, no reset and the observed generation. Their multi-generation preparation -protocol remains future work. Submit once and read back after uncertain responses. +resource/Runtime edits use the observed generation and the current node generation +protocol. Installer-managed nodes advertise automatic generation preparation; manual +fixed-configuration nodes retain their enrolled generation and require explicit +operator preparation for a new target. Submit once and read back after uncertain responses. Deployment rollout reports old-generation resource counts separately from preparation. Poll frequently only while reset is active or rollout.state is preparing. E2B updates diff --git a/services/agents-api/IMPLEMENTATION.md b/services/agents-api/IMPLEMENTATION.md index 52373bcc6..d64dbe105 100644 --- a/services/agents-api/IMPLEMENTATION.md +++ b/services/agents-api/IMPLEMENTATION.md @@ -931,11 +931,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti completion text snapshot. A snapshot is not another delta; uncompleted messages remain partial when their Turn ends. Keep these observations in the journal before projecting public Items. This does not promise daemon event replay. -- Legacy `tool_items` / `observe_tools` raw snapshots remain available to old - daemon callers. New Agents API execution does not request or decode them. - `tool_observations` advertises engine-neutral tool snapshots. The opt-in - `observe_tool_observations` takes precedence over legacy `observe_tools`: - attach the typed `observation` to existing tool-call frames without `native_item`. + `observe_tool_observations` attaches the typed `observation` to tool-call frames. Native adapters own discriminator/status/action translation and preserve raw structured values; reuse the shared function-result content type. Kinds are `command`, `mcp`, `function` and `web_search`; observation status is @@ -1202,9 +1199,11 @@ and owner epoch. Promote a durable pin only for readiness of the then-current ta under deployment serialization. Late superseded readiness cannot acquire a pin. Filter online/readiness/address/capacity before preferring the newest eligible pin; newest-full must not mask older-free. Route Create, restore and cleanup through the -immutable allocation/placement generation. V1 keeps its qualified enrolled fallback. +immutable allocation/placement generation. Fixed-configuration manual nodes serve +only their enrolled generation; generation management is an explicit hello capability +on the same current wire protocol, never a historical-version fallback. -Use sparse v2 control batches of at most eight entries with no lifetime cap. Omitted +Use sparse generation control batches of at most eight entries with no lifetime cap. Omitted facts never authorize deletion. Correlate whole retention grants to connection, epoch, sequence, generation and digest; recheck queued/inflight/helper references. Permanent generation flock files survive updates and GC. Before any helper starts, @@ -1217,7 +1216,7 @@ Only transfer/checksum/provenance errors report runtime_download_failed; preserv other fixed causes without parsing native error text. Retained Runtime bytes and the console's exact-release HTTP allowlist must agree. See `contracts/agents-api/node-generation-protocol.md` for recovery, immutable artifacts -and the conservative v1 legacy-helper retention boundary. +and conservative retention of unproven historical helper ownership. Interrupted node collection keeps an exact private generation journal and immutable configuration. Restart treats it only as a candidate for a fresh correlated Core drop grant, never as preparation or serving readiness. Persist native cleanup @@ -1255,7 +1254,7 @@ alone never imply preparation or high-frequency polling. Rollout state is settle unless actual target preparation is active. Offline/unconfirmed nodes are unknown; only exact current connection/epoch/generation readiness is ready. A durable pin is not connectivity. Fixed diagnostics alone may explain failed preparation. Report -preparing only from an actual target observation; an online v1 node can be +preparing only from an actual target observation; an online fixed-configuration node can be update_required while its valid pin remains available for admission. Reset is durable execution state, advanced by the existing manager outside its diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 6e190d07e..1e5000398 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -545,13 +545,11 @@ idle input returns 202 after preparation/admission, not after model completion; use client/proxy timeouts above five minutes and recover progress through events and reads. Exact upstream failure/error timing remains unverified. -The 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. +The Runtime's shared Go workspace implementation owns directory reads, writes and +output export; Harness adapters use the same authorized workspace binding. +The [qualification record](../../contracts/agents-api/user-managed-runtime-v1.md) +identifies fixed-SDK/raw HTTP, real-model, Files/Artifacts, cancellation, +restart/history and credential lifecycle evidence, with its recorded revision limits. ### HTTP MCP execution diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index 74d3ff86c..e618023f1 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -161,8 +161,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, 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). +For key rotation, executor-key revocation and supported execution 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/sandbox-node/main.go b/services/agents-api/cmd/sandbox-node/main.go index d40842849..a1065e99e 100644 --- a/services/agents-api/cmd/sandbox-node/main.go +++ b/services/agents-api/cmd/sandbox-node/main.go @@ -48,7 +48,7 @@ func run(ctx context.Context, args []string) error { } if len(args) == 1 && args[0] == "protocol-version" { - fmt.Println(node.GenerationProtocolVersion) + fmt.Println(node.ProtocolVersion) return nil } if len(args) == 0 || (args[0] != "register" && args[0] != "run") { diff --git a/services/agents-api/cmd/specification-contract/main.go b/services/agents-api/cmd/specification-contract/main.go new file mode 100644 index 000000000..ba47c03f0 --- /dev/null +++ b/services/agents-api/cmd/specification-contract/main.go @@ -0,0 +1,35 @@ +// specification-contract maintains the generated node installer projection. +package main + +import ( + "flag" + "fmt" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "os" + "strings" +) + +func main() { + write := flag.Bool("write", false, "update deploy/install/node_spec.py from the repository root") + flag.Parse() + projection := sandbox.PythonDeploymentContract() + if !*write { + fmt.Println(projection) + return + } + path := "deploy/install/node_spec.py" + raw, err := os.ReadFile(path) + if err != nil { + panic(err) + } + source := string(raw) + start := strings.Index(source, "# BEGIN GENERATED DEPLOYMENT CONTRACT") + end := strings.Index(source, "# END GENERATED DEPLOYMENT CONTRACT") + if start < 0 || end < start { + panic("missing generated contract markers") + } + end += len("# END GENERATED DEPLOYMENT CONTRACT") + if err = os.WriteFile(path, []byte(source[:start]+projection+source[end:]), 0644); err != nil { + panic(err) + } +} diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md index 57e125814..6acca018c 100644 --- a/services/agents-api/deploy/e2b/README.md +++ b/services/agents-api/deploy/e2b/README.md @@ -85,7 +85,7 @@ 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`. +execution. Managed startup uses the separate [Runtime bootstrap contract](../../../../docs/runtime-bootstrap.md). Generate and retain an application launch UUID once. `launch.py` is a thin SDK example, not a service or a replacement lifecycle owner: diff --git a/services/agents-api/deploy/e2b/managed_init.py b/services/agents-api/deploy/e2b/managed_init.py index 9a1d635f5..038a21040 100644 --- a/services/agents-api/deploy/e2b/managed_init.py +++ b/services/agents-api/deploy/e2b/managed_init.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 -"""One-shot managed bootstrap using the existing daemon authentication profile.""" +"""One-shot managed startup through the Runtime bootstrap contract.""" import importlib.util import json import os from pathlib import Path import subprocess -from urllib.parse import urlsplit from uuid import UUID # -I excludes the script directory from sys.path; load only its protected sibling. @@ -16,19 +15,13 @@ def identity(payload): fields = ['InstallationID', 'TenantID', 'EnvironmentID', 'AllocationID', 'SessionID', 'DeviceID'] - if not isinstance(payload, dict) or set(payload) != set(fields + ['CoreURL', 'Credential', 'NetworkAccess', 'AllowedDomains']): + if not isinstance(payload, dict) or set(payload) != set(fields + ['NetworkAccess', 'AllowedDomains', 'RuntimeBootstrap']): raise ValueError('Invalid managed bootstrap fields') for field in fields: value = payload[field] if not isinstance(value, str) or str(UUID(value)) != value or UUID(value).int == 0: raise ValueError('Invalid managed bootstrap identity') - address = urlsplit(payload['CoreURL']) - if (address.scheme not in ('http', 'https') or not address.hostname or address.username is not None or - address.query or address.fragment or any(char.isspace() for char in payload['CoreURL'])): - raise ValueError('Invalid managed bootstrap address') - if (not isinstance(payload['Credential'], str) or not payload['Credential'] or - any(char.isspace() or char == '\0' for char in payload['Credential']) or - payload['NetworkAccess'] not in ('enabled', 'disabled', 'restricted') or + if (payload['NetworkAccess'] not in ('enabled', 'disabled', 'restricted') or payload['AllowedDomains'] is not None and (not isinstance(payload['AllowedDomains'], list) or any(not isinstance(domain, str) for domain in payload['AllowedDomains']))): @@ -53,14 +46,14 @@ def initialize(): OAC_RUNTIME_SESSION_ID=payload['SessionID'], OAC_RUNTIME_NETWORK_ACCESS=payload['NetworkAccess'], OAC_RUNTIME_ALLOWED_DOMAINS=json.dumps(payload['AllowedDomains'] or [])) - shared.write_private(shared.PROFILE / 'auth.json', - {'server_url': payload['CoreURL'], 'runtime_id': payload['DeviceID'], - 'runner_credential': payload['Credential']}, owner=1000) + connection = Path(environment['OAC_RUNTIME_HOME']).parent / 'runtime-bootstrap.json' + shared.write_private(connection, payload['RuntimeBootstrap'], owner=1000) source.unlink() with (shared.PROFILE / 'daemon.log').open('xb') as stream: os.fchmod(stream.fileno(), 0o600) os.fchown(stream.fileno(), 1000, 1000) - child = subprocess.Popen(['/usr/local/bin/oac-daemon', 'connect', '--profile', 'default'], + child = subprocess.Popen(['/usr/local/bin/oac-daemon', 'connect', '--profile', 'default', + '--bootstrap-file', str(connection)], 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) diff --git a/services/agents-api/deploy/e2b/managed_init_test.py b/services/agents-api/deploy/e2b/managed_init_test.py index a71df6226..8dadcaa6d 100644 --- a/services/agents-api/deploy/e2b/managed_init_test.py +++ b/services/agents-api/deploy/e2b/managed_init_test.py @@ -13,15 +13,15 @@ def payload(): value = {key: str(uuid4()) for key in ['InstallationID', 'TenantID', 'EnvironmentID', 'AllocationID', 'SessionID', 'DeviceID']} - return dict(value, CoreURL='https://core.example/api/v1', Credential='private-managed-token', + return dict(value, RuntimeBootstrap={'version': 1, 'core_url': 'https://core.example/api/v1', + 'device_id': value['DeviceID'], 'credential': 'private-managed-token'}, NetworkAccess='restricted', AllowedDomains=['example.com']) class ManagedStartupTest(unittest.TestCase): def test_invalid_binding_rejected(self): source = payload() - for key, value in [('DeviceID', 'other'), ('CoreURL', 'https://secret@host/api/v1'), - ('Credential', 'x\n'), ('NetworkAccess', 'unknown')]: + for key, value in [('DeviceID', 'other'), ('NetworkAccess', 'unknown')]: with self.subTest(key=key), self.assertRaises(ValueError): managed_init.identity(dict(source, **{key: value})) @@ -37,7 +37,7 @@ def exercise(self, failed=False): process = Mock(return_value=Mock(pid=456)) if failed: process.side_effect = RuntimeError('private process diagnostic') - image_env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'OAC_RUNTIME_HOME': '/home/runtime/.oac', + image_env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'OAC_RUNTIME_HOME': str(Path(temporary) / '.oac'), 'OAC_RUNTIME_WORKSPACE': '/environment/workspace'} with patch.object(managed_init.shared, 'ROOT', root), patch.object(managed_init.shared, 'PROFILE', profile), \ patch.object(managed_init.shared, 'prepare_runtime', return_value=image_env), \ @@ -48,13 +48,14 @@ def exercise(self, failed=False): else: managed_init.initialize() self.assertTrue((root / 'managed-launch.json').exists()) - auth = json.loads((profile / 'auth.json').read_text()) - self.assertEqual(auth['runner_credential'], data['Credential']) - self.assertEqual(auth['runtime_id'], data['DeviceID']) - self.assertEqual((profile / 'auth.json').stat().st_mode & 0o777, 0o600) + connection_file = Path(temporary) / 'runtime-bootstrap.json' + self.assertEqual(json.loads(connection_file.read_text()), data['RuntimeBootstrap']) + self.assertEqual(connection_file.stat().st_mode & 0o777, 0o600) + self.assertFalse((profile / 'auth.json').exists()) + self.assertEqual(process.call_args.args[0][-2:], ['--bootstrap-file', str(connection_file)]) self.assertFalse(source.exists()) - self.assertNotIn(data['Credential'], json.dumps(process.call_args.args)) - self.assertNotIn(data['Credential'], json.dumps(process.call_args.kwargs['env'])) + self.assertNotIn(data['RuntimeBootstrap']['credential'], json.dumps(process.call_args.args)) + self.assertNotIn(data['RuntimeBootstrap']['credential'], json.dumps(process.call_args.kwargs['env'])) self.assertEqual(process.call_args.kwargs['env']['OAC_RUNTIME_ENVIRONMENT_ID'], data['EnvironmentID']) self.assertEqual(process.call_args.kwargs['user'], 1000) if failed: @@ -62,7 +63,7 @@ def exercise(self, failed=False): else: receipt = json.loads((root / 'managed-ready.json').read_text()) self.assertEqual(receipt['identity'], managed_init.identity(data)) - self.assertNotIn(data['Credential'], json.dumps(receipt)) + self.assertNotIn(data['RuntimeBootstrap']['credential'], json.dumps(receipt)) source.write_text(json.dumps(data)) with self.assertRaises(RuntimeError): managed_init.initialize() diff --git a/services/agents-api/internal/execution/sandbox_deployment_drain_test.go b/services/agents-api/internal/execution/sandbox_deployment_drain_test.go index f7d7cfff1..0aece86b4 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_drain_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_drain_test.go @@ -106,7 +106,7 @@ func testLifecycleCancellationPreservesLease(t *testing.T, mode string) { hub := node.NewHub(node.HubOptions{}) defer hub.Close() id := uuid.NewString() - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(lease.Store(), gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return configuration, nil })) if err != nil { t.Fatal(err) @@ -244,7 +244,7 @@ func TestSandboxDeploymentDrainFailureCannotReactivate(t *testing.T) { hub := node.NewHub(node.HubOptions{}) defer hub.Close() id := uuid.NewString() - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(lease.Store(), gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return configuration, nil })) if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go index 213230c0a..41d52dc4f 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup_test.go @@ -22,7 +22,7 @@ func TestDeferredSandboxDeploymentLoadsOnceBeforeNodeCreation(t *testing.T) { id := uuid.NewString() var selected atomic.Bool var loads atomic.Int32 - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { loads.Add(1) if !selected.Load() { @@ -92,7 +92,7 @@ func TestDeferredSandboxProviderFailureKeepsRecoveryAvailable(t *testing.T) { id := uuid.NewString() available := false loadErr := ErrExecutionUnavailable - configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + configuration := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { if !available { return nil, loadErr @@ -123,7 +123,7 @@ func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { hub := node.NewHub(node.HubOptions{}) defer hub.Close() id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} rejected := errors.New("candidate provider unavailable") m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil }, func(context.Context, store.SandboxSetup) (PreparedRuntimeDeployment, error) { @@ -197,7 +197,7 @@ func TestCommittedSandboxCandidatePublishesAfterShutdown(t *testing.T) { if err := m.pauseDeployment(t.Context()); err != nil { t.Fatal(err) } - config := &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config := &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} var published *RuntimeProvider candidate := PreparedRuntimeDeployment{Config: config, Publish: func(value *RuntimeProvider) { published = value }} m.stop() diff --git a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go b/services/agents-api/internal/execution/sandbox_deployment_switch_test.go index 2d78d268d..19e900a3c 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/execution/sandbox_deployment_switch_test.go @@ -17,7 +17,7 @@ func TestSandboxManagerSwitchDrainsBeforeDirectActivation(t *testing.T) { hub := node.NewHub(node.HubOptions{}) defer hub.Close() id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) if err != nil { t.Fatal(err) @@ -61,7 +61,7 @@ func TestSandboxManagerSwitchDrainsBeforeDirectActivation(t *testing.T) { case <-time.After(time.Second): t.Fatal("switch drain blocked") } - config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} if err := m.activateDeployment(t.Context(), store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"}); err != nil { t.Fatal(err) } @@ -99,7 +99,7 @@ func TestSandboxManagerCancelledSwitchCannotResumeBeforeDrain(t *testing.T) { hub := node.NewHub(node.HubOptions{}) defer hub.Close() id := uuid.NewString() - config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config := &RuntimeProvider{InstallationID: id, ProviderKind: "docker", Mode: "nodes", Generation: 1, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) if err != nil { t.Fatal(err) @@ -128,7 +128,7 @@ func TestSandboxManagerCancelledSwitchCannotResumeBeforeDrain(t *testing.T) { m.mu.Lock() originalDrain := m.switchDrained m.mu.Unlock() - config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker")} + config = &RuntimeProvider{InstallationID: id, ProviderKind: "e2b", Mode: "direct", Generation: 2, CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("b", 64), Provider: hub.Proxy(uuid.NewString(), "docker", 1)} expected := store.RuntimeDeploymentView{InstallationID: id, Generation: 2, Mode: "direct", Provider: "e2b"} ctx, cancel = context.WithTimeout(t.Context(), 10*time.Millisecond) if err := m.activateDeployment(ctx, expected); !errors.Is(err, context.DeadlineExceeded) { @@ -157,7 +157,7 @@ func TestSandboxActivationCannotBypassOutstandingDrain(t *testing.T) { m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil }, func(_ context.Context, setup store.SandboxSetup) (PreparedRuntimeDeployment, error) { - return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker")}}, nil + return PreparedRuntimeDeployment{Config: &RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}}, nil })) if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/execution/sandbox_generations_test.go b/services/agents-api/internal/execution/sandbox_generations_test.go index f322419b0..4b816b214 100644 --- a/services/agents-api/internal/execution/sandbox_generations_test.go +++ b/services/agents-api/internal/execution/sandbox_generations_test.go @@ -28,7 +28,7 @@ func TestE2BReplacementVerifiesTwiceAndNeverPublishesFailedCommit(t *testing.T) } hub := node.NewHub(node.HubOptions{}) defer hub.Close() - provider := hub.Proxy(uuid.NewString(), "docker") + provider := hub.Proxy(uuid.NewString(), "docker", 1) verifyCalls, published, fenced, released := 0, 0, 0, 0 var rejectAt int var rejection error = e2b.ErrTeamMismatch diff --git a/services/agents-api/internal/execution/sandbox_reset_test.go b/services/agents-api/internal/execution/sandbox_reset_test.go index f2fcca3b5..7517d3007 100644 --- a/services/agents-api/internal/execution/sandbox_reset_test.go +++ b/services/agents-api/internal/execution/sandbox_reset_test.go @@ -107,7 +107,7 @@ func TestSandboxResetPageTimeoutRecoversCommittedOwner(t *testing.T) { if err != nil || setup.Provider == "" { return nil, err } - return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Mode: setup.Mode, Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker")}, nil + return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Mode: setup.Mode, Generation: setup.Generation, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}, nil }) m, err := newRuntimeManager(w, gateway.NewRegistry(), config) if err != nil { diff --git a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go index 339f18f5c..6f4a95325 100644 --- a/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go +++ b/services/agents-api/internal/execution/sandbox_snapshot_budget_test.go @@ -80,7 +80,7 @@ func TestSandboxResetSnapshotFitsPageBudget(t *testing.T) { if err != nil || setup.Provider == "" { return nil, err } - return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker")}, nil + return &RuntimeProvider{InstallationID: id, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, CoreURL: "https://core.example/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: hub.Proxy(uuid.NewString(), "docker", 1)}, nil }) m, err := newRuntimeManager(w, gateway.NewRegistry(), configuration) if err != nil { diff --git a/services/agents-api/internal/sandbox/deployment.go b/services/agents-api/internal/sandbox/deployment.go index eeb8f44a3..9c03ed7eb 100644 --- a/services/agents-api/internal/sandbox/deployment.go +++ b/services/agents-api/internal/sandbox/deployment.go @@ -5,7 +5,8 @@ import ( "encoding/hex" "encoding/json" "fmt" - "strings" + "reflect" + "regexp" ) // ValidationError preserves the sandbox error text and identity while identifying @@ -30,31 +31,32 @@ type Resources struct { } func (r Resources) Validate(provider string) error { - if r.CPUs == 0 || r.CPUs > 255 || r.MemoryMiB < 512 || r.MemoryMiB > 1048576 { - field, min, max := "resources.cpus", uint32(1), uint32(255) - if r.CPUs > 0 && r.CPUs <= 255 { - field, min, max = "resources.memory_mib", 512, 1048576 - } - return &ValidationError{Param: field, Min: &min, Max: &max, Message: fmt.Sprintf("%s: cpus must be 1..255 and memory_mib must be 512..1048576", ErrInvalid)} - } - switch provider { - case "microsandbox": - if r.RootDiskMiB < 1024 || r.EnvironmentDiskMiB < 1024 { - field := "resources.root_disk_mib" - if r.RootDiskMiB >= 1024 { - field = "resources.environment_disk_mib" + rules, ok := deploymentProviders[provider] + values := reflect.ValueOf(r) + for i, rule := range resourceContract { + min, max := rule.Min, rule.Max + message := rule.Message + var maximum *uint32 = validationBound(max) + if rule.OmitZero { + if !ok { + break } - return &ValidationError{Param: field, Min: validationBound(1024), Message: fmt.Sprintf("%s: microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", ErrInvalid)} - } - case "docker", "e2b": - if r.RootDiskMiB != 0 || r.EnvironmentDiskMiB != 0 { - field := "resources.root_disk_mib" - if r.RootDiskMiB == 0 { - field = "resources.environment_disk_mib" + min, max = 0, 0 + message = provider + " does not support independent disk capacity limits" + if rules.Disk { + min, max = minimumDiskMiB, ^uint32(0) + message = "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB" + maximum = nil + } else { + maximum = validationBound(max) } - return &ValidationError{Param: field, Min: validationBound(0), Max: validationBound(0), Message: fmt.Sprintf("%s: %s does not support independent disk capacity limits", ErrInvalid, provider)} } - default: + value := values.Field(i).Uint() + if value < uint64(min) || value > uint64(max) { + return &ValidationError{Param: "resources." + rule.Name, Min: validationBound(min), Max: maximum, Message: fmt.Sprintf("%s: %s", ErrInvalid, message)} + } + } + if !ok { return fmt.Errorf("%w: unsupported sandbox provider", ErrInvalid) } return nil @@ -78,11 +80,11 @@ func lowerHex(v string, bytes int) bool { } func (r RuntimeRelease) Validate() error { - if !lowerHex(r.SourceCommit, 20) || !strings.HasPrefix(r.ImageID, "sha256:") || !lowerHex(strings.TrimPrefix(r.ImageID, "sha256:"), 32) || - !strings.HasPrefix(r.ImageManifestDigest, "sha256:") || !lowerHex(strings.TrimPrefix(r.ImageManifestDigest, "sha256:"), 32) || - !strings.HasPrefix(r.MicrosandboxRef, "oac-runtime@sha256:") || !lowerHex(strings.TrimPrefix(r.MicrosandboxRef, "oac-runtime@sha256:"), 32) || - !lowerHex(r.RuntimeSHA256, 32) || !lowerHex(r.FirmwareSHA256, 32) { - return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} + values := reflect.ValueOf(r) + for i, rule := range runtimeContract { + if !regexp.MustCompile("^(?:" + rule.Pattern + ")$").MatchString(values.Field(i).String()) { + return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} + } } return nil } @@ -96,7 +98,7 @@ func (s DeploymentSpec) Validate(provider string) error { if err := s.Resources.Validate(provider); err != nil { return err } - if provider == "e2b" { + if !deploymentProviders[provider].Runtime { if s.Runtime != nil { return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: E2B Runtime is selected by its immutable template build", ErrInvalid)} } diff --git a/services/agents-api/internal/sandbox/deployment_contract.go b/services/agents-api/internal/sandbox/deployment_contract.go new file mode 100644 index 000000000..a0688211f --- /dev/null +++ b/services/agents-api/internal/sandbox/deployment_contract.go @@ -0,0 +1,88 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// This contract owns numeric bounds, release patterns and canonical field order. +// Python installers consume its generated projection in node_spec.py. +const minimumDiskMiB uint32 = 1024 + +type resourceRule struct { + Name string `json:"name"` + Min uint32 `json:"min"` + Max uint32 `json:"max"` + OmitZero bool `json:"omit_zero"` + Message string `json:"-"` +} +type runtimeRule struct { + Name string `json:"name"` + Pattern string `json:"pattern"` +} +type providerRule struct { + Disk bool `json:"disk"` + Runtime bool `json:"runtime"` +} + +var resourceContract = []resourceRule{ + {"cpus", 1, 255, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, + {"memory_mib", 512, 1048576, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, + {"root_disk_mib", 0, ^uint32(0), true, ""}, + {"environment_disk_mib", 0, ^uint32(0), true, ""}, +} +var runtimeContract = []runtimeRule{ + {"source_commit", "[0-9a-f]{40}"}, + {"image_id", "sha256:[0-9a-f]{64}"}, + {"image_manifest_digest", "sha256:[0-9a-f]{64}"}, + {"microsandbox_ref", "oac-runtime@sha256:[0-9a-f]{64}"}, + {"runtime_sha256", "[0-9a-f]{64}"}, + {"firmware_sha256", "[0-9a-f]{64}"}, +} +var deploymentProviders = map[string]providerRule{ + "docker": {Runtime: true}, "microsandbox": {Disk: true, Runtime: true}, "e2b": {}, +} + +// PythonDeploymentContract generates the installer projection. Struct order is +// checked before generation because it also defines Go's canonical JSON bytes. +func PythonDeploymentContract() string { + for _, item := range []struct { + value any + names []string + }{ + {Resources{}, resourceNames()}, {RuntimeRelease{}, runtimeNames()}, + } { + typ := reflect.TypeOf(item.value) + if typ.NumField() != len(item.names) { + panic("deployment contract field count differs") + } + for i, name := range item.names { + if strings.Split(typ.Field(i).Tag.Get("json"), ",")[0] != name { + panic("deployment contract field order differs") + } + } + } + raw, _ := json.Marshal(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]providerRule `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, deploymentProviders, minimumDiskMiB}) + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" +} +func resourceNames() []string { + var names []string + for _, r := range resourceContract { + names = append(names, r.Name) + } + return names +} +func runtimeNames() []string { + var names []string + for _, r := range runtimeContract { + names = append(names, r.Name) + } + return names +} diff --git a/services/agents-api/internal/sandbox/deployment_contract_test.go b/services/agents-api/internal/sandbox/deployment_contract_test.go new file mode 100644 index 000000000..26f96a890 --- /dev/null +++ b/services/agents-api/internal/sandbox/deployment_contract_test.go @@ -0,0 +1,58 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" +) + +func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { + raw, err := os.ReadFile("../../../../deploy/install/node_spec.py") + if err != nil { + t.Fatal(err) + } + expected := PythonDeploymentContract() + if !strings.Contains(string(raw), expected) { + t.Fatal("node_spec.py contract is stale; regenerate with go run ./services/agents-api/cmd/specification-contract -write") + } +} +func TestDeploymentContractFixtures(t *testing.T) { + raw, err := os.ReadFile("testdata/deployment-contract.json") + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + Name, Provider, Canonical, Digest string + Specification json.RawMessage + Valid bool + } + if err = json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, fixture := range fixtures { + t.Run(fixture.Name, func(t *testing.T) { + var spec DeploymentSpec + decoder := json.NewDecoder(bytes.NewReader(fixture.Specification)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&spec) + if err == nil { + err = spec.Validate(fixture.Provider) + } + if (err == nil) != fixture.Valid { + t.Fatalf("validation differs: %v", err) + } + if !fixture.Valid { + return + } + canonical, _ := json.Marshal(struct { + Provider string `json:"provider"` + DeploymentSpec + }{fixture.Provider, spec}) + if string(canonical) != fixture.Canonical || spec.Digest(fixture.Provider) != fixture.Digest { + t.Fatalf("canonical contract differs: %s", canonical) + } + }) + } +} diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go index fc75168cf..22b02ef04 100644 --- a/services/agents-api/internal/sandbox/docker/bootstrap.go +++ b/services/agents-api/internal/sandbox/docker/bootstrap.go @@ -4,7 +4,6 @@ import ( "archive/tar" "bytes" "context" - "encoding/json" "io" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" @@ -18,21 +17,14 @@ type entry struct { } func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap) error { - // The existing daemon auth profile is the shared managed/user-managed boundary. - // This file is injected before the untrusted workspace or harness can run. - auth, e := json.Marshal(struct { - ServerURL string `json:"server_url"` - RuntimeID string `json:"runtime_id"` - Credential string `json:"runner_credential"` - }{b.CoreURL, b.DeviceID, b.Credential}) + // Deliver the Runtime-owned connection contract before native work can start. + auth, e := b.RuntimeConnection().Marshal() if e != nil { return e } if e = copyRuntimeFiles(ctx, p.client, id, "/home", []entry{ {name: "runtime", directory: true}, {name: "runtime/.oac", directory: true}, - {name: "runtime/.oac/daemon", directory: true}, - {name: "runtime/.oac/daemon/default", directory: true}, - {name: "runtime/.oac/daemon/default/auth.json", content: auth}, + {name: "runtime/runtime-bootstrap.json", content: auth}, }); e != nil { return e } diff --git a/services/agents-api/internal/sandbox/docker/bootstrap_test.go b/services/agents-api/internal/sandbox/docker/bootstrap_test.go new file mode 100644 index 000000000..64edb000b --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/bootstrap_test.go @@ -0,0 +1,62 @@ +package docker + +import ( + "archive/tar" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/moby/moby/client" +) + +func TestBootstrapDeliversOnlyPublicConnectionInput(t *testing.T) { + b := sandbox.Bootstrap{CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-secret"} + found := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" || !strings.HasSuffix(r.URL.Path, "/containers/test/archive") { + t.Errorf("unexpected Docker operation %s", r.URL.Path) + } + tr := tar.NewReader(r.Body) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Error(err) + break + } + if strings.Contains(h.Name, "auth.json") { + t.Error("provider wrote Runtime private storage") + } + if h.Name == "runtime/runtime-bootstrap.json" { + found = true + raw, err := io.ReadAll(tr) + if err != nil { + t.Error(err) + } + c, err := runtimebootstrap.Decode(raw) + if err != nil || c != b.RuntimeConnection() || h.Mode != 0600 || h.Uid != 1000 || h.Gid != 1000 { + t.Error("invalid launch input or permissions") + } + } + } + w.WriteHeader(200) + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err := (&Provider{client: c}).bootstrap(t.Context(), "test", b); err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("missing Runtime input") + } +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 589443553..e74b905dc 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" @@ -113,9 +112,8 @@ func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info 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) policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} - if policy.Validate() != nil || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + if policy.Validate() != nil || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { return info, sandbox.ErrInvalid } if existing, e := p.GetInfo(ctx, b.Reference); e == nil { @@ -151,7 +149,9 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf return info, sandbox.ErrOwnership } } - v, e := p.client.ContainerCreate(ctx, runtimeContainerOptions(p.config, name, p.labels(b.Reference), []string{"OAC_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "OAC_RUNTIME_SESSION_ID=" + b.SessionID, "OAC_RUNTIME_NETWORK_ACCESS=" + policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS=" + string(domains)})) + options := runtimeContainerOptions(p.config, name, p.labels(b.Reference), []string{"OAC_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "OAC_RUNTIME_SESSION_ID=" + b.SessionID, "OAC_RUNTIME_NETWORK_ACCESS=" + policy.Access, "OAC_RUNTIME_ALLOWED_DOMAINS=" + string(domains)}) + options.Config.Cmd = []string{"connect", "--profile", "default", "--bootstrap-file", "/home/runtime/runtime-bootstrap.json"} + v, e := p.client.ContainerCreate(ctx, options) if errdefs.IsConflict(e) { return info, sandbox.ErrExists } diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go index 000b6caf2..16d859d26 100644 --- a/services/agents-api/internal/sandbox/docker/provider_test.go +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -5,13 +5,13 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "errors" "os" "strings" "testing" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" @@ -137,12 +137,12 @@ func TestDockerProviderLifecycle(t *testing.T) { if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { t.Fatalf("duplicate not rejected: %v", e) } - r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/.oac/daemon/default/auth.json"}}) + r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/runtime-bootstrap.json"}}) if e != nil { t.Fatal(e) } - var auth map[string]string - if json.Unmarshal([]byte(r.Stdout), &auth) != nil || auth["runner_credential"] != b.Credential || auth["runtime_id"] != b.DeviceID { + auth, decodeErr := runtimebootstrap.Decode([]byte(r.Stdout)) + if decodeErr != nil || auth.Credential != b.Credential || auth.DeviceID != b.DeviceID { t.Fatal("bootstrap changed or malformed") } r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "printf retained > /environment/workspace/history; printf failed >&2; exit 7"}}) diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index a3b611e32..bd9cd3ee3 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "net/url" "os" "path/filepath" "strings" @@ -13,6 +12,7 @@ import ( "unicode" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/google/uuid" ) @@ -36,10 +36,11 @@ type Request struct { Config Config Reference sandbox.Reference // References lists the allocations of one read-only observe request. - References []sandbox.Reference `json:",omitempty"` - Bootstrap *sandbox.Bootstrap `json:",omitempty"` - Command *sandbox.Command `json:",omitempty"` - Deadline time.Time + References []sandbox.Reference `json:",omitempty"` + Bootstrap *sandbox.Bootstrap `json:",omitempty"` + RuntimeBootstrap *runtimebootstrap.Connection `json:",omitempty"` + Command *sandbox.Command `json:",omitempty"` + Deadline time.Time } type Response struct { Version int @@ -205,7 +206,15 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen if err := ctx.Err(); err != nil { return unstarted(operation, r), err } - out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, Command: command, Deadline: deadline}) + var connection *runtimebootstrap.Connection + if b != nil { + value := b.RuntimeConnection() + if value.Validate() != nil { + return unstarted(operation, r), sandbox.ErrInvalid + } + connection = &value + } + out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, RuntimeBootstrap: connection, Command: command, Deadline: deadline}) if errors.Is(err, errHelperNotStarted) { return unstarted(operation, r), sandbox.ErrComputeUnconfirmed } @@ -221,8 +230,8 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen switch out.ErrorCode { case "": return out, nil - case "legacy_template": - return out, fmt.Errorf("%w: This E2B template was built before OpenAgentCore renamed its paths. Build a template with this release's build-template.py and replace it in the sandbox deployment.", sandbox.ErrInvalid) + case "template_invalid": + return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) case "team_mismatch": return out, ErrTeamMismatch case "unauthorized": @@ -272,9 +281,8 @@ func (p *Provider) info(ctx context.Context, operation string, r sandbox.Referen return sandbox.Info{Reference: r}, err } func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { - u, err := url.Parse(b.CoreURL) policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} - if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || policy.Validate() != nil || err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || policy.Validate() != nil || b.RuntimeConnection().Validate() != nil { info := sandbox.Info{Reference: b.Reference} if validReference(b.Reference) { info.State, info.CreateSettled = "absent", true diff --git a/services/agents-api/internal/sandbox/e2b/provider_test.go b/services/agents-api/internal/sandbox/e2b/provider_test.go index 4a7941e7e..8d88aef25 100644 --- a/services/agents-api/internal/sandbox/e2b/provider_test.go +++ b/services/agents-api/internal/sandbox/e2b/provider_test.go @@ -160,12 +160,12 @@ func TestDefinitePreHelperCreateFailureCarriesAbsenceProof(t *testing.T) { } } -func TestLegacyTemplateHasSafeActionableDiagnostic(t *testing.T) { +func TestInvalidTemplateHasSafeActionableDiagnostic(t *testing.T) { p, f, r := fixture(t) - f.response.ErrorCode = "legacy_template" + f.response.ErrorCode = "template_invalid" f.response.Info = &sandbox.Info{Reference: r, ProviderID: "owned", State: "running", CreateSettled: true} info, err := p.GetInfo(bounded(t), r) if !errors.Is(err, sandbox.ErrInvalid) || !strings.Contains(err.Error(), "Build a template with this release's build-template.py") || !info.CreateSettled { - t.Fatalf("legacy template: %+v %v", info, err) + t.Fatalf("invalid template: %+v %v", info, err) } } diff --git a/services/agents-api/internal/sandbox/microsandbox/identity.go b/services/agents-api/internal/sandbox/microsandbox/identity.go index 59d6130bc..4fe7c6f2e 100644 --- a/services/agents-api/internal/sandbox/microsandbox/identity.go +++ b/services/agents-api/internal/sandbox/microsandbox/identity.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "net/url" "path/filepath" "strings" @@ -77,8 +76,7 @@ func ValidateSnapshot(c Config, r sandbox.Reference, s SnapshotIdentity) error { return nil } func ValidateBootstrap(b sandbox.Bootstrap) error { - u, e := url.Parse(b.CoreURL) - if !ValidReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + if !ValidReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || b.RuntimeConnection().Validate() != nil { return sandbox.ErrInvalid } return (agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains}).Validate() diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go index a570a01fa..7b95f08ed 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/agents-api/internal/sandbox/node/agent.go @@ -209,17 +209,14 @@ func (a *agent) connect(ctx context.Context) error { health, _ := a.health(ctx, host) _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second)) version := ProtocolVersion - if a.config.Generations != nil { - version = GenerationProtocolVersion - } - if err = writeFrame(conn, frame{Version: version, Type: "hello", Identity: &a.config.Identity, Health: &health}); err != nil { + if err = writeFrame(conn, frame{Version: version, Type: "hello", GenerationManagement: a.config.Generations != nil, Identity: &a.config.Identity, Health: &health}); err != nil { return err } welcome, err := readFrame(conn) if err != nil { return err } - if welcome.Version != version || welcome.Type != "welcome" || !validID(welcome.ConnectionID) || welcome.OwnerEpoch == 0 { + if (a.config.Generations == nil && welcome.Deployment != nil) || welcome.Version != version || welcome.Type != "welcome" || !validID(welcome.ConnectionID) || welcome.OwnerEpoch == 0 { return sandbox.ErrInvalid } a.mu.Lock() @@ -246,6 +243,9 @@ func (a *agent) connect(ctx context.Context) error { var controlDone chan struct{} controlWork := make(chan []sandbox.GenerationRetention, 1) if a.config.Generations != nil { + if welcome.Deployment == nil { + return sandbox.ErrInvalid + } if err := a.config.Generations.Deployment(*welcome.Deployment); err != nil { return err } @@ -264,9 +264,12 @@ func (a *agent) connect(ctx context.Context) error { return sandbox.ErrInvalid } if f.Type == "heartbeat_ack" && f.ConnectionID == current.id { - if version == GenerationProtocolVersion { - if f.OwnerEpoch != current.epoch { - return sandbox.ErrOwnership + if f.OwnerEpoch != current.epoch || (a.config.Generations == nil && f.Deployment != nil) { + return sandbox.ErrOwnership + } + if a.config.Generations != nil { + if f.Deployment == nil { + return sandbox.ErrInvalid } if err := a.config.Generations.Deployment(*f.Deployment); err != nil { return err @@ -274,7 +277,7 @@ func (a *agent) connect(ctx context.Context) error { } continue } - if f.Type == "retention_ack" && version == GenerationProtocolVersion { + if f.Type == "retention_ack" && a.config.Generations != nil { if err := a.acceptRetention(current, f, controlWork); err != nil { return err } @@ -284,6 +287,9 @@ func (a *agent) connect(ctx context.Context) error { return sandbox.ErrInvalid } q := *f.Request + if a.config.Generations == nil && q.DeploymentGeneration != a.config.Identity.DeploymentGeneration { + return sandbox.ErrOwnership + } if q.ConnectionID != current.id || q.OwnerEpoch != current.epoch || q.Sequence != sequence+1 { return sandbox.ErrOwnership } diff --git a/services/agents-api/internal/sandbox/node/capacity_test.go b/services/agents-api/internal/sandbox/node/capacity_test.go index 4a21dd357..1de42f08a 100644 --- a/services/agents-api/internal/sandbox/node/capacity_test.go +++ b/services/agents-api/internal/sandbox/node/capacity_test.go @@ -14,7 +14,7 @@ import ( func TestCapacityRefreshUsesAuthenticatedCoreIdentity(t *testing.T) { id := identity() var stored StoredIdentity - approved := EnrollmentResponse{NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8} + approved := EnrollmentResponse{SpecificationDigest: id.SpecificationDigest, DeploymentGeneration: id.DeploymentGeneration, NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/sandbox-node/identity" || r.URL.Query().Get("node_id") != id.NodeID || r.Header.Get("Authorization") != "Bearer "+stored.Credential { t.Error("identity refresh lacked retained authority") @@ -74,7 +74,7 @@ func TestHubCapacityCacheCannotOverrideAdminChangesOrBlockReconnect(t *testing.T mu.Lock() approved.MaxActive, approved.MaxRetained = 2, 8 mu.Unlock() - if err := writeFrame(conn, frame{Type: "heartbeat", ConnectionID: peer.id, Health: &Health{ProviderReady: true}}); err != nil { + if err := writeFrame(conn, frame{Type: "heartbeat", ConnectionID: peer.id, OwnerEpoch: peer.epoch, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(time.Second)) diff --git a/services/agents-api/internal/sandbox/node/connection_test.go b/services/agents-api/internal/sandbox/node/connection_test.go index a1da83978..3a1558214 100644 --- a/services/agents-api/internal/sandbox/node/connection_test.go +++ b/services/agents-api/internal/sandbox/node/connection_test.go @@ -49,7 +49,7 @@ func connectRawNode(t *testing.T, origin string, id Identity) *websocket.Conn { if err != nil { t.Fatal(err) } - if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true}}); err != nil { + if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { conn.Close() t.Fatal(err) } @@ -174,7 +174,7 @@ func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { hub.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) r := reference() created := make(chan error, 1) go func() { _, err := proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); created <- err }() diff --git a/services/agents-api/internal/sandbox/node/creation_settlement_test.go b/services/agents-api/internal/sandbox/node/creation_settlement_test.go index 005452d67..02d56f13c 100644 --- a/services/agents-api/internal/sandbox/node/creation_settlement_test.go +++ b/services/agents-api/internal/sandbox/node/creation_settlement_test.go @@ -71,7 +71,7 @@ func TestNodeCarriesCreationSettlementWithoutConvertingFailureToSuccess(t *testi } }() wait(t, func() bool { return hub.Online(id.NodeID) }) - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) for _, operation := range []func(context.Context) (sandbox.Info, error){ func(ctx context.Context) (sandbox.Info, error) { return proxy.Create(ctx, sandbox.Bootstrap{Reference: ref}) diff --git a/services/agents-api/internal/sandbox/node/docker_live_test.go b/services/agents-api/internal/sandbox/node/docker_live_test.go index f77b72061..3673945b6 100644 --- a/services/agents-api/internal/sandbox/node/docker_live_test.go +++ b/services/agents-api/internal/sandbox/node/docker_live_test.go @@ -75,7 +75,7 @@ func TestDockerNodeTransportLifecycle(t *testing.T) { } }() wait(t, func() bool { return hub.Online(id.NodeID) }) - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) r := reference() defer func() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) diff --git a/services/agents-api/internal/sandbox/node/generation_json.go b/services/agents-api/internal/sandbox/node/generation_json.go index ad98b470e..a3cda6163 100644 --- a/services/agents-api/internal/sandbox/node/generation_json.go +++ b/services/agents-api/internal/sandbox/node/generation_json.go @@ -70,12 +70,14 @@ func generationRecords(raw []byte, required, optional string) error { } func validateGenerationJSON(raw []byte, kind string) error { - var fields string + var fields, optional string switch kind { case "hello": fields = "identity health" + optional = "generation_management" case "welcome", "heartbeat_ack": - fields = "deployment connection_id owner_epoch" + fields = "connection_id owner_epoch" + optional = "deployment" case "heartbeat": fields = "health connection_id owner_epoch" case "retention": @@ -89,7 +91,7 @@ func validateGenerationJSON(raw []byte, kind string) error { default: return sandbox.ErrInvalid } - values, err := generationObject(raw, "version type "+fields, "", "") + values, err := generationObject(raw, "version type "+fields, optional, "") if err != nil { return err } diff --git a/services/agents-api/internal/sandbox/node/generation_json_test.go b/services/agents-api/internal/sandbox/node/generation_json_test.go index 9c3b7242f..aaacee1a9 100644 --- a/services/agents-api/internal/sandbox/node/generation_json_test.go +++ b/services/agents-api/internal/sandbox/node/generation_json_test.go @@ -11,7 +11,7 @@ import ( ) func TestGenerationGrantJSONRefusesAmbiguousDeletionAuthority(t *testing.T) { - f := frame{Version: 2, Type: "retention_ack", Deployment: &sandbox.NodeDeployment{Generation: 2, SpecificationDigest: strings.Repeat("a", 64)}, Control: &generationControl{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Sequence: 1, OwnerEpoch: 1, Retentions: []sandbox.GenerationRetention{{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("b", 64)}, Keep: true}}}} + f := frame{Version: ProtocolVersion, Type: "retention_ack", Deployment: &sandbox.NodeDeployment{Generation: 2, SpecificationDigest: strings.Repeat("a", 64)}, Control: &generationControl{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Sequence: 1, OwnerEpoch: 1, Retentions: []sandbox.GenerationRetention{{GenerationReference: sandbox.GenerationReference{Generation: 1, SpecificationDigest: strings.Repeat("b", 64)}, Keep: true}}}} raw, err := json.Marshal(f) if err != nil { t.Fatal(err) @@ -43,7 +43,7 @@ func TestGenerationGrantJSONRefusesAmbiguousDeletionAuthority(t *testing.T) { } func TestGenerationHealthRejectsUnboundedOrAmbiguousNumbers(t *testing.T) { - f := frame{Version: 2, Type: "heartbeat", ConnectionID: uuid.NewString(), OwnerEpoch: 1, Health: &Health{ObservedAt: time.Now().UTC()}} + f := frame{Version: ProtocolVersion, Type: "heartbeat", ConnectionID: uuid.NewString(), OwnerEpoch: 1, Health: &Health{ObservedAt: time.Now().UTC()}} good, _ := json.Marshal(f) if _, err := decodeFrame(good); err != nil { t.Fatal(err) @@ -61,3 +61,32 @@ func TestGenerationHealthRejectsUnboundedOrAmbiguousNumbers(t *testing.T) { } } } + +func TestNodeProtocolRejectsHistoricalVersions(t *testing.T) { + for _, version := range []int{1, 2} { + raw, _ := json.Marshal(frame{Version: version, Type: "hello", Identity: new(Identity), Health: &Health{ObservedAt: time.Now().UTC()}}) + if _, err := decodeFrame(raw); err == nil { + t.Fatalf("accepted historical protocol %d", version) + } + } +} + +func TestNodeGenerationManagementIsAnExplicitCurrentCapability(t *testing.T) { + for _, managed := range []bool{false, true} { + f := frame{Version: ProtocolVersion, Type: "hello", GenerationManagement: managed, Identity: new(Identity), Health: &Health{ObservedAt: time.Now().UTC()}} + if managed { + f.Health.Generations = []sandbox.GenerationStatus{{Generation: 1, SpecificationDigest: strings.Repeat("a", 64), State: "ready"}} + } + raw, _ := json.Marshal(f) + if _, err := decodeFrame(raw); err != nil { + t.Fatalf("current mode managed=%v: %v", managed, err) + } + if managed { + f.GenerationManagement = false + raw, _ = json.Marshal(f) + if _, err := decodeFrame(raw); err == nil { + t.Fatal("generation management accepted without declaration") + } + } + } +} diff --git a/services/agents-api/internal/sandbox/node/generation_wire.go b/services/agents-api/internal/sandbox/node/generation_wire.go index 7aab4d4cd..d37cbc34e 100644 --- a/services/agents-api/internal/sandbox/node/generation_wire.go +++ b/services/agents-api/internal/sandbox/node/generation_wire.go @@ -25,13 +25,10 @@ func validSpecificationDigest(v string) bool { } func validateVersionFrame(f frame, size int) error { - if f.Version == ProtocolVersion { - if f.Deployment != nil || f.Control != nil || f.Health != nil && f.Health.Generations != nil || f.Request != nil && f.Request.DeploymentGeneration != 0 { - return sandbox.ErrInvalid - } - return nil + if f.Version != ProtocolVersion { + return sandbox.ErrInvalid } - if f.Version != GenerationProtocolVersion { + if f.GenerationManagement && f.Type != "hello" { return sandbox.ErrInvalid } if f.Type != "request" && f.Type != "response" && size > MaxControlFrameBytes { @@ -75,11 +72,14 @@ func validateVersionFrame(f frame, size int) error { } switch f.Type { case "hello": + if !f.GenerationManagement && f.Health != nil && f.Health.Generations != nil { + return sandbox.ErrInvalid + } if f.Identity == nil || f.Health == nil || f.Request != nil || f.Response != nil || f.Control != nil || f.Deployment != nil { return sandbox.ErrInvalid } case "welcome", "heartbeat_ack": - if f.Deployment == nil || !validID(f.ConnectionID) || !validGeneration(f.OwnerEpoch) || f.Health != nil || f.Identity != nil || f.Request != nil || f.Response != nil || f.Control != nil { + if !validID(f.ConnectionID) || !validGeneration(f.OwnerEpoch) || f.Health != nil || f.Identity != nil || f.Request != nil || f.Response != nil || f.Control != nil { return sandbox.ErrInvalid } case "heartbeat": diff --git a/services/agents-api/internal/sandbox/node/hub.go b/services/agents-api/internal/sandbox/node/hub.go index ddefcfdc5..88284fece 100644 --- a/services/agents-api/internal/sandbox/node/hub.go +++ b/services/agents-api/internal/sandbox/node/hub.go @@ -35,21 +35,22 @@ type Hub struct { } type peer struct { - version int - generations map[uint64]sandbox.GenerationStatus - controlSequence uint64 - identity Identity - id string - epoch uint64 - conn *websocket.Conn - send chan struct{} - mu sync.Mutex - sequence uint64 - ready bool - pending map[string]chan response - done chan struct{} - once sync.Once - cancel context.CancelFunc + generationManagement bool + version int + generations map[uint64]sandbox.GenerationStatus + controlSequence uint64 + identity Identity + id string + epoch uint64 + conn *websocket.Conn + send chan struct{} + mu sync.Mutex + sequence uint64 + ready bool + pending map[string]chan response + done chan struct{} + once sync.Once + cancel context.CancelFunc } func NewHub(options HubOptions) *Hub { @@ -158,7 +159,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = conn.Close() return } - p := &peer{version: hello.Version, generations: map[uint64]sandbox.GenerationStatus{}, identity: identity, id: uuid.NewString(), epoch: epoch, conn: conn, cancel: cancel, send: make(chan struct{}, 1), pending: map[string]chan response{}, ready: hello.Health.ProviderReady, done: make(chan struct{})} + p := &peer{generationManagement: hello.GenerationManagement, version: hello.Version, generations: map[uint64]sandbox.GenerationStatus{}, identity: identity, id: uuid.NewString(), epoch: epoch, conn: conn, cancel: cancel, send: make(chan struct{}, 1), pending: map[string]chan response{}, ready: hello.Health.ProviderReady, done: make(chan struct{})} stopPeerClose := context.AfterFunc(ctx, p.close) defer stopPeerClose() presenceAttempted := false @@ -191,7 +192,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } welcome := frame{Version: p.version, Type: "welcome", ConnectionID: p.id, OwnerEpoch: epoch} - if p.version == GenerationProtocolVersion { + if p.generationManagement { deployment, _, err := h.retention(ctx, p, nil) if err != nil { return @@ -241,7 +242,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { if e != nil || current != epoch { return } - if p.version == GenerationProtocolVersion && f.OwnerEpoch != p.epoch { + if f.OwnerEpoch != p.epoch { return } if h.recordHealth(ctx, p, *f.Health) != nil { @@ -251,7 +252,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } ack := frame{Version: p.version, Type: "heartbeat_ack", ConnectionID: p.id, OwnerEpoch: p.epoch} - if p.version == GenerationProtocolVersion { + if p.generationManagement { deployment, _, readErr := h.retention(ctx, p, nil) if readErr != nil { p.unlockSend() @@ -265,7 +266,7 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } case "retention": - if p.version != GenerationProtocolVersion || f.Control == nil { + if !p.generationManagement || f.Control == nil { return } c := f.Control @@ -329,10 +330,10 @@ func (h *Hub) call(ctx context.Context, id string, q request) (response, error) } p.mu.Lock() ready := p.ready - if p.version == GenerationProtocolVersion { + if p.generationManagement { status, ok := p.generations[q.DeploymentGeneration] ready = ok && status.State == "ready" - } else if q.DeploymentGeneration != 0 && q.DeploymentGeneration != p.identity.DeploymentGeneration { + } else if q.DeploymentGeneration != p.identity.DeploymentGeneration { p.mu.Unlock() return response{}, uncertain(q.Operation, ErrUnavailable) } @@ -340,9 +341,7 @@ func (h *Hub) call(ctx context.Context, id string, q request) (response, error) if requiresReady(q) && !ready { return response{}, uncertain(q.Operation, ErrUnavailable) } - if p.version == ProtocolVersion { - q.DeploymentGeneration = 0 - } + deadline, _ := ctx.Deadline() q.ID, q.ConnectionID, q.OwnerEpoch = uuid.NewString(), p.id, p.epoch if err = q.setTimeout(deadline); err != nil { @@ -391,10 +390,11 @@ func (h *Hub) call(ctx context.Context, id string, q request) (response, error) } } -type Resolver func(context.Context, sandbox.Reference) (string, error) - func (h *Hub) recordHealth(ctx context.Context, p *peer, health Health) error { - if p.version == GenerationProtocolVersion { + if !p.generationManagement && health.Generations != nil { + return sandbox.ErrInvalid + } + if p.generationManagement { if h.options.Generations == nil { return sandbox.ErrInvalid } diff --git a/services/agents-api/internal/sandbox/node/hub_lifetime_test.go b/services/agents-api/internal/sandbox/node/hub_lifetime_test.go index d72021c21..27d4dbc23 100644 --- a/services/agents-api/internal/sandbox/node/hub_lifetime_test.go +++ b/services/agents-api/internal/sandbox/node/hub_lifetime_test.go @@ -21,7 +21,7 @@ func beginRawNode(t *testing.T, origin string, id Identity) *websocket.Conn { t.Fatal(err) } t.Cleanup(func() { conn.Close() }) - if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true}}); err != nil { + if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { t.Fatal(err) } return conn @@ -44,7 +44,7 @@ func assertInfoResponsive(t *testing.T, hub *Hub, id Identity) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - info, err := hub.Proxy(id.NodeID, id.Provider).GetInfo(ctx, reference()) + info, err := hub.Proxy(id.NodeID, id.Provider, 1).GetInfo(ctx, reference()) if err != nil || info.ProviderID != "retained" { t.Fatalf("unrelated node RPC blocked: info=%+v err=%v", info, err) } @@ -124,7 +124,7 @@ func TestHubCloseCancelsEveryOpeningCallback(t *testing.T) { return } defer conn.Close() - _ = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true}}) + _ = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}) if f, err := readFrame(conn); err == nil { t.Errorf("canceled opening published welcome: %s", f.Type) } @@ -284,7 +284,7 @@ func TestHubSendQueueRespectsCallerCancellation(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := hub.Proxy(id.NodeID, id.Provider).GetInfo(ctx, reference()); done <- err }() + go func() { _, err := hub.Proxy(id.NodeID, id.Provider, 1).GetInfo(ctx, reference()); done <- err }() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { @@ -422,7 +422,7 @@ func TestHubDisconnectCancelsPeriodicHeartbeatCallback(t *testing.T) { hub.mu.Lock() connection := hub.peers[id.NodeID].id hub.mu.Unlock() - if err := writeFrame(conn, frame{Type: "heartbeat", ConnectionID: connection, Health: &Health{ProviderReady: true}}); err != nil { + if err := writeFrame(conn, frame{Type: "heartbeat", ConnectionID: connection, OwnerEpoch: 2, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { t.Fatal(err) } select { diff --git a/services/agents-api/internal/sandbox/node/node_test.go b/services/agents-api/internal/sandbox/node/node_test.go index 7a1e523b5..eed327bf7 100644 --- a/services/agents-api/internal/sandbox/node/node_test.go +++ b/services/agents-api/internal/sandbox/node/node_test.go @@ -60,7 +60,7 @@ func reference() sandbox.Reference { return sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} } func identity() Identity { - return Identity{NodeID: uuid.NewString(), InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64), MaxActive: 4, MaxRetained: 16} + return Identity{DeploymentGeneration: 1, SpecificationDigest: strings.Repeat("a", 64), NodeID: uuid.NewString(), InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64), MaxActive: 4, MaxRetained: 16} } func stateDir(t *testing.T) string { t.Helper() @@ -122,7 +122,7 @@ func TestLostCreateResponseDoesNotReplayAndReconnectSerializesCleanup(t *testing t.Fatal("running node did not retain lifetime identity lock") } r := reference() - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) createCtx, stopCreate := context.WithTimeout(ctx, 150*time.Millisecond) defer stopCreate() createDone := make(chan error, 1) @@ -165,7 +165,7 @@ func TestLostCreateResponseDoesNotReplayAndReconnectSerializesCleanup(t *testing func TestOfflineIsUnknownAndDockerDoesNotAdvertiseCheckpoint(t *testing.T) { h := NewHub(HubOptions{OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) - p := h.Proxy(uuid.NewString(), "docker") + p := h.Proxy(uuid.NewString(), "docker", 1) if _, ok := p.(sandbox.CheckpointProvider); ok { t.Fatal("docker advertised checkpoint") } @@ -191,7 +191,7 @@ func TestAgentRejectsDuplicateSequenceAndRetainsEpoch(t *testing.T) { } connectionID := uuid.NewString() _ = writeFrame(conn, frame{Type: "welcome", ConnectionID: connectionID, OwnerEpoch: 9}) - q := request{ID: uuid.NewString(), Sequence: 1, ConnectionID: connectionID, OwnerEpoch: 9, Operation: "info", TimeoutMillis: 60000, Reference: reference()} + q := request{DeploymentGeneration: id.DeploymentGeneration, ID: uuid.NewString(), Sequence: 1, ConnectionID: connectionID, OwnerEpoch: 9, Operation: "info", TimeoutMillis: 60000, Reference: reference()} _ = writeFrame(conn, frame{Type: "request", Request: &q}) _, _ = readFrame(conn) q.ID = uuid.NewString() @@ -233,7 +233,7 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) return } - _ = json.NewEncoder(w).Encode(EnrollmentResponse{NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8}) + _ = json.NewEncoder(w).Encode(EnrollmentResponse{SpecificationDigest: id.SpecificationDigest, DeploymentGeneration: id.DeploymentGeneration, NodeID: id.NodeID, InstallationID: id.InstallationID, Provider: id.Provider, MaxActive: 2, MaxRetained: 8}) return } if r.URL.Path != "/api/v1/sandbox-node/enroll" || r.Header.Get("Authorization") != "Bearer enrollment" { diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go index 547d4f7f5..952227ebe 100644 --- a/services/agents-api/internal/sandbox/node/observations_test.go +++ b/services/agents-api/internal/sandbox/node/observations_test.go @@ -86,11 +86,11 @@ func TestObservationsRouteThroughAssignedNodeWithoutLifecycleCalls(t *testing.T) stopSecond := runObservationNode(t, hub, server.URL, second, b) ra, rb := reference(), reference() assignments := map[sandbox.Reference]string{ra: first.NodeID, rb: second.NodeID} - source := hub.Provider("docker", func(_ context.Context, r sandbox.Reference) (string, error) { + source := hub.GenerationProvider("docker", func(_ context.Context, r sandbox.Reference) (string, uint64, error) { if id, ok := assignments[r]; ok { - return id, nil + return id, 1, nil } - return "", sandbox.ErrOwnership + return "", 0, sandbox.ErrOwnership }).(runtimeobs.Source) if typed := source.(interface{ ObservationProviderType() string }).ObservationProviderType(); typed != "docker" { t.Fatal("provider type lost", typed) diff --git a/services/agents-api/internal/sandbox/node/proxy.go b/services/agents-api/internal/sandbox/node/proxy.go index 83c159306..9635464e2 100644 --- a/services/agents-api/internal/sandbox/node/proxy.go +++ b/services/agents-api/internal/sandbox/node/proxy.go @@ -7,7 +7,6 @@ import ( type provider struct { hub *Hub - resolve Resolver resolveGeneration func(context.Context, sandbox.Reference) (string, uint64, error) kind string } @@ -16,31 +15,19 @@ type checkpointProvider struct{ *provider } var _ sandbox.SandboxProvider = (*provider)(nil) var _ sandbox.CheckpointProvider = (*checkpointProvider)(nil) -// Provider advertises checkpoint support only for a deployment using microsandbox. -func (h *Hub) Provider(kind string, resolve Resolver) sandbox.SandboxProvider { - p := &provider{hub: h, resolve: resolve, kind: kind} - if kind == "microsandbox" { - return &checkpointProvider{p} - } - return p -} -func (h *Hub) Proxy(id, kind string) sandbox.SandboxProvider { - return h.Provider(kind, func(context.Context, sandbox.Reference) (string, error) { return id, nil }) +// Proxy binds a fixed node and deployment generation explicitly. +func (h *Hub) Proxy(id, kind string, generation uint64) sandbox.SandboxProvider { + return h.GenerationProvider(kind, func(context.Context, sandbox.Reference) (string, uint64, error) { return id, generation, nil }) } func (p *provider) call(ctx context.Context, q request) (response, error) { - var id string - var err error - if p.resolveGeneration != nil { - id, q.DeploymentGeneration, err = p.resolveGeneration(ctx, q.Reference) - } else { - id, err = p.resolve(ctx, q.Reference) - } + id, generation, err := p.resolveGeneration(ctx, q.Reference) if err != nil { return response{}, err } - if !validID(id) { + if !validID(id) || !validGeneration(generation) { return response{}, sandbox.ErrOwnership } + q.DeploymentGeneration = generation return p.hub.call(ctx, id, q) } func (p *provider) info(ctx context.Context, q request) (sandbox.Info, error) { diff --git a/services/agents-api/internal/sandbox/node/recovery_test.go b/services/agents-api/internal/sandbox/node/recovery_test.go index 6936f0f75..aa7db298b 100644 --- a/services/agents-api/internal/sandbox/node/recovery_test.go +++ b/services/agents-api/internal/sandbox/node/recovery_test.go @@ -55,10 +55,10 @@ func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) if first.Online(id.NodeID) { t.Fatal("old owner remained online") } - if _, err = first.Proxy(id.NodeID, "docker").GetInfo(context.Background(), reference()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { + if _, err = first.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { t.Fatalf("old owner request = %v", err) } - if _, err = second.Proxy(id.NodeID, "docker").GetInfo(context.Background(), reference()); err != nil { + if _, err = second.Proxy(id.NodeID, "docker", 1).GetInfo(context.Background(), reference()); err != nil { t.Fatal(err) } stop() @@ -154,7 +154,7 @@ func TestHeartbeatAcknowledgementKeepsIdleConnectionAlive(t *testing.T) { if !hub.Online(id.NodeID) { t.Fatal("idle node disconnected") } - if _, err = hub.Proxy(id.NodeID, "docker").GetInfo(ctx, reference()); err != nil { + if _, err = hub.Proxy(id.NodeID, "docker", 1).GetInfo(ctx, reference()); err != nil { t.Fatal(err) } } @@ -174,7 +174,7 @@ func TestHubExpiresSilentNode(t *testing.T) { t.Fatal(err) } defer conn.Close() - if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true}}); err != nil { + if err = writeFrame(conn, frame{Type: "hello", Identity: &id, Health: &Health{ProviderReady: true, ObservedAt: time.Now().UTC()}}); err != nil { t.Fatal(err) } if _, err = readFrame(conn); err != nil { @@ -232,7 +232,7 @@ func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { case <-time.After(time.Second): t.Fatal("missing health") } - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) r := reference() if _, err = proxy.Create(ctx, sandbox.Bootstrap{Reference: r}); !errors.Is(err, sandbox.ErrComputeUnconfirmed) { t.Fatalf("create = %v", err) diff --git a/services/agents-api/internal/sandbox/node/timeout_test.go b/services/agents-api/internal/sandbox/node/timeout_test.go index ad3738df7..5d82ca48a 100644 --- a/services/agents-api/internal/sandbox/node/timeout_test.go +++ b/services/agents-api/internal/sandbox/node/timeout_test.go @@ -101,7 +101,7 @@ func TestQueuedMutationExpiresWithoutExecution(t *testing.T) { } }() wait(t, func() bool { return hub.Online(id.NodeID) }) - proxy := hub.Proxy(id.NodeID, "docker") + proxy := hub.Proxy(id.NodeID, "docker", 1) r := reference() createCtx, stopCreate := context.WithTimeout(ctx, 3*time.Second) defer stopCreate() diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go index e04a719ba..c735b96fd 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/agents-api/internal/sandbox/node/wire.go @@ -16,8 +16,7 @@ import ( "github.com/gorilla/websocket" ) -const ProtocolVersion = 1 -const GenerationProtocolVersion = 2 +const ProtocolVersion = 3 const MaxControlFrameBytes = 32 * 1024 const MaxFrameBytes = 72 * 1024 * 1024 const maxPending = 32 @@ -108,16 +107,17 @@ type response struct { } type frame struct { - Deployment *sandbox.NodeDeployment `json:"deployment,omitempty"` - Control *generationControl `json:"control,omitempty"` - Version int `json:"version"` - Type string `json:"type"` - Identity *Identity `json:"identity,omitempty"` - Health *Health `json:"health,omitempty"` - ConnectionID string `json:"connection_id,omitempty"` - OwnerEpoch uint64 `json:"owner_epoch,omitempty"` - Request *request `json:"request,omitempty"` - Response *response `json:"response,omitempty"` + GenerationManagement bool `json:"generation_management,omitempty"` + Deployment *sandbox.NodeDeployment `json:"deployment,omitempty"` + Control *generationControl `json:"control,omitempty"` + Version int `json:"version"` + Type string `json:"type"` + Identity *Identity `json:"identity,omitempty"` + Health *Health `json:"health,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` + OwnerEpoch uint64 `json:"owner_epoch,omitempty"` + Request *request `json:"request,omitempty"` + Response *response `json:"response,omitempty"` } func validID(s string) bool { @@ -143,10 +143,10 @@ func decodeFrame(data []byte) (frame, error) { var f frame d := json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() - if d.Decode(&f) != nil || d.Decode(new(any)) != io.EOF || (f.Version != ProtocolVersion && f.Version != GenerationProtocolVersion) { + if d.Decode(&f) != nil || d.Decode(new(any)) != io.EOF || f.Version != ProtocolVersion { return frame{}, sandbox.ErrInvalid } - if f.Version == GenerationProtocolVersion && validateGenerationJSON(data, f.Type) != nil { + if validateGenerationJSON(data, f.Type) != nil { return frame{}, sandbox.ErrInvalid } if err := validateVersionFrame(f, len(data)); err != nil { diff --git a/services/agents-api/internal/sandbox/runtime_bootstrap.go b/services/agents-api/internal/sandbox/runtime_bootstrap.go new file mode 100644 index 000000000..36d4a1ba7 --- /dev/null +++ b/services/agents-api/internal/sandbox/runtime_bootstrap.go @@ -0,0 +1,12 @@ +package sandbox + +import "github.com/MiniMax-AI-Dev/parsar/internal/runtimebootstrap" + +// RuntimeConnection projects provider allocation input into Runtime's public +// startup contract. Providers must never construct a private auth profile. +func (b Bootstrap) RuntimeConnection() runtimebootstrap.Connection { + return runtimebootstrap.Connection{ + Version: runtimebootstrap.Version, CoreURL: b.CoreURL, + DeviceID: b.DeviceID, Credential: b.Credential, + } +} diff --git a/services/agents-api/internal/sandbox/testdata/deployment-contract.json b/services/agents-api/internal/sandbox/testdata/deployment-contract.json new file mode 100644 index 000000000..afec83248 --- /dev/null +++ b/services/agents-api/internal/sandbox/testdata/deployment-contract.json @@ -0,0 +1,238 @@ +[ + { + "name": "docker", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": true, + "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", + "digest": "1237a0d19e665b387922ad3d60143d5cd530864f0e3e4ee2c5ace57a1134aa38" + }, + { + "name": "docker-zero-disks", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096, + "root_disk_mib": 0, + "environment_disk_mib": 0 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": true, + "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", + "digest": "1237a0d19e665b387922ad3d60143d5cd530864f0e3e4ee2c5ace57a1134aa38" + }, + { + "name": "microsandbox", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": true, + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", + "digest": "1bb6daadcf9e34b236a8ecba6eb4c5215b946a3f226721b2bf148f72acb5f05e" + }, + { + "name": "e2b", + "provider": "e2b", + "specification": { + "resources": { + "cpus": 1, + "memory_mib": 512 + } + }, + "valid": true, + "canonical": "{\"provider\":\"e2b\",\"resources\":{\"cpus\":1,\"memory_mib\":512}}", + "digest": "bfedd8252478cfeb7e927ccd9aab6e812e0a35405046db3cf023fe62a869de04" + }, + { + "name": "cpu-zero", + "provider": "docker", + "specification": { + "resources": { + "cpus": 0, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "cpu-overflow", + "provider": "docker", + "specification": { + "resources": { + "cpus": 256, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "cpu-bool", + "provider": "docker", + "specification": { + "resources": { + "cpus": true, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "memory-low", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 511 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "unknown-resource", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096, + "other": 1 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "docker-disk", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096, + "root_disk_mib": 1024 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "micro-small-disk", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1023, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + }, + { + "name": "invalid-release-digest", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "valid": false + } +] diff --git a/services/agents-api/internal/store/execution_tools_test.go b/services/agents-api/internal/store/execution_tools_test.go index 347aec57b..a8bd2e077 100644 --- a/services/agents-api/internal/store/execution_tools_test.go +++ b/services/agents-api/internal/store/execution_tools_test.go @@ -19,7 +19,7 @@ func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { env := h.read(testExecutionRequest) var request proto.PromptRequestPayload _ = env.DecodePayload(&request) - if !request.ObserveToolObservations || request.ObserveTools || request.ObserveMessages { + if !request.ObserveToolObservations || request.ObserveMessages { t.Fatal("advertised capability was not requested") } start := json.RawMessage(`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"key":"value"},"status":"in_progress","output":null,"error":null}`) diff --git a/services/agents-api/internal/store/subagent_item_projection.go b/services/agents-api/internal/store/subagent_item_projection.go index 4f70c94a8..5e5e57ee9 100644 --- a/services/agents-api/internal/store/subagent_item_projection.go +++ b/services/agents-api/internal/store/subagent_item_projection.go @@ -103,7 +103,7 @@ func childItems(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn } case proto.TypeToolCall: var call proto.ToolCallPayload - if json.Unmarshal(p.Payload, &call) != nil || call.ID != p.ItemID || len(call.NativeItem) > 0 || call.Observation == nil { + if json.Unmarshal(p.Payload, &call) != nil || call.ID != p.ItemID || call.Observation == nil { return result, ErrInvalidInput } case "message": diff --git a/services/agents-api/tools/e2b-provider/provider.py b/services/agents-api/tools/e2b-provider/provider.py index 99925d257..d50c06d20 100644 --- a/services/agents-api/tools/e2b-provider/provider.py +++ b/services/agents-api/tools/e2b-provider/provider.py @@ -219,14 +219,16 @@ def create(self): except Failure: self.receipt.save(status='configuration_rejected', settled=True) raise - # Refuse a pre-rename template before writing any executor credential. + # Validate the current template entry point before writing any credential. check = run(cloud, {'Args': ['/usr/bin/python3', '-I', '-c', - "import os,sys; sys.exit(78 if not os.path.isfile('/opt/oac-e2b/managed_init.py') and os.path.isdir('/opt/parsar-e2b') else 0)"]}, + "import os,sys; sys.exit(78 if not os.path.isfile('/opt/oac-e2b/managed_init.py') or not os.access('/opt/oac-e2b/managed_init.py', os.R_OK) else 0)"]}, self.remaining, user='root') if check['ExitCode'] != 0: self.receipt.save(status='bootstrap_failed', settled=True) - raise Failure('legacy_template' if check['ExitCode'] == 78 else 'unconfirmed') - payload = dict(bootstrap, InstallationID=self.config['InstallationID']) + raise Failure('template_invalid' if check['ExitCode'] == 78 else 'unconfirmed') + payload = dict(bootstrap, InstallationID=self.config['InstallationID'], + RuntimeBootstrap=self.q['RuntimeBootstrap']) + del payload['CoreURL'], payload['Credential'] cloud.files.write('/root/.oac/e2b/managed-bootstrap.json', json.dumps(payload), user='root', request_timeout=self.remaining()) self.receipt.save(status='bootstrap_pending') diff --git a/services/agents-api/tools/e2b-provider/provider_test.py b/services/agents-api/tools/e2b-provider/provider_test.py index ba4c6807f..74d708547 100644 --- a/services/agents-api/tools/e2b-provider/provider_test.py +++ b/services/agents-api/tools/e2b-provider/provider_test.py @@ -29,6 +29,10 @@ def setUp(self): 'Bootstrap': dict(self.reference, SessionID=str(uuid4()), DeviceID=str(uuid4()), CoreURL='https://core.example/api/v1', Credential='private-runtime-secret', NetworkAccess='enabled', AllowedDomains=[])} + self.request['RuntimeBootstrap'] = { + 'version': 1, 'core_url': self.request['Bootstrap']['CoreURL'], + 'device_id': self.request['Bootstrap']['DeviceID'], + 'credential': self.request['Bootstrap']['Credential']} self.cloud = Mock(sandbox_id='owned-id', sandbox_domain='e2b.app', _envd_version='0.5.0', _envd_access_token='private-envd-secret', traffic_access_token=None, state='running') self.cloud.metadata = Provider(self.request).metadata @@ -62,6 +66,10 @@ def test_create_recover_and_never_replay(self): self.assertEqual(result['ErrorCode'], '') self.assertTrue(result['Info']['BootstrapComplete']) self.assertTrue(result['Info']['CreateSettled']) + startup = json.loads(self.cloud.files.write.call_args.args[1]) + self.assertEqual(startup['RuntimeBootstrap'], self.request['RuntimeBootstrap']) + self.assertNotIn('CoreURL', startup) + self.assertNotIn('Credential', startup) self.assertEqual(self.call('create')['ErrorCode'], 'exists') self.assertTrue(self.call('inspect')['Info']['BootstrapComplete']) self.api.create.assert_called_once() @@ -136,10 +144,10 @@ def test_create_refuses_foreign_data_plane_before_envd(self): self.cloud.files.write.assert_not_called() self.cloud.commands.run.assert_not_called() - def test_legacy_template_refuses_before_credentials_and_retains_owned_cleanup(self): + def test_template_invalid_refuses_before_credentials_and_retains_owned_cleanup(self): with patch('provider.run', return_value={'ExitCode': 78, 'Stdout': '', 'Stderr': ''}): result = self.call('create') - self.assertEqual(result['ErrorCode'], 'legacy_template') + self.assertEqual(result['ErrorCode'], 'template_invalid') self.assertTrue(result['Info']['CreateSettled']) self.assertFalse(result['Info']['BootstrapComplete']) self.assertEqual(self.record()['ids'], ['owned-id']) diff --git a/services/agents-api/tools/microsandbox-provider/README.md b/services/agents-api/tools/microsandbox-provider/README.md index ab1169cde..87af8167b 100644 --- a/services/agents-api/tools/microsandbox-provider/README.md +++ b/services/agents-api/tools/microsandbox-provider/README.md @@ -78,10 +78,10 @@ external volume lifecycle is introduced. Creation starts in `/` until bootstrap creates the workspace directories. VM creation does not implicitly run the OCI ENTRYPOINT. Before admitting native -work, the helper uses confidential stdin to install the existing private -`auth.json` format, create Runtime directories, bind the same workspace at -`/workspace`, and invoke the existing daemon's `connect --profile default -b` -mode as uid/gid 1000. The final provider-owned bootstrap label confirms only +work, the helper delivers the [Runtime bootstrap input](../../../../docs/runtime-bootstrap.md) +through confidential stdin to a protected file, creates Runtime directories, binds +the workspace at `/workspace`, and launches `connect --bootstrap-file` in +background mode as uid/gid 1000. The final provider-owned bootstrap label confirms only completion of these writes and launch, not authentication or native readiness. The receipt label uses the supported next-start modification policy to update persisted metadata without restarting the guest. v0.7.2 cannot update active labels. diff --git a/services/agents-api/tools/microsandbox-provider/bootstrap.go b/services/agents-api/tools/microsandbox-provider/bootstrap.go index 9797a2482..7eef9c4c3 100644 --- a/services/agents-api/tools/microsandbox-provider/bootstrap.go +++ b/services/agents-api/tools/microsandbox-provider/bootstrap.go @@ -13,19 +13,19 @@ import ( sdk "github.com/superradcompany/microsandbox/sdk/go" ) -// The existing auth profile and daemon's own background mode are reused. +// Runtime reads the shared launch input; its private auth storage stays opaque. // All credential bytes enter the guest on stdin before any native work is admitted. const bootstrapScript = ` import ctypes,json,os,stat,subprocess,sys b=json.load(sys.stdin) -for p in ['/home/runtime','/home/runtime/.oac','/home/runtime/.oac/daemon','/home/runtime/.oac/daemon/default','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages','/run/oac']: +for p in ['/home/runtime','/home/runtime/.oac','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages','/run/oac']: os.makedirs(p,mode=0o700,exist_ok=True) if not stat.S_ISDIR(os.lstat(p).st_mode): raise RuntimeError('invalid bootstrap directory') os.chmod(p,0o700);os.chown(p,1000,1000) -p='/home/runtime/.oac/daemon/default/auth.json' +p='/home/runtime/runtime-bootstrap.json' fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) with os.fdopen(fd,'w') as f: - json.dump({'server_url':b['CoreURL'],'runtime_id':b['DeviceID'],'runner_credential':b['Credential']},f) + json.dump(b,f) f.flush();os.fsync(f.fileno());os.fchown(f.fileno(),1000,1000) if not stat.S_ISDIR(os.lstat('/workspace').st_mode): raise RuntimeError('invalid workspace alias') libc=ctypes.CDLL(None,use_errno=True) @@ -33,7 +33,7 @@ if libc.mount(b'/environment/workspace',b'/workspace',None,4096,None)!=0: raise OSError(ctypes.get_errno(),'workspace bind mount failed') def runtime_user(): os.setgroups([]);os.setgid(1000);os.setuid(1000) -subprocess.run(['/usr/local/bin/oac-daemon','connect','--profile','default','-b'], +subprocess.run(['/usr/local/bin/oac-daemon','connect','--profile','default','--bootstrap-file',p,'-b'], stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, cwd='/environment/workspace',preexec_fn=runtime_user,check=True) ` @@ -79,7 +79,7 @@ func (b backend) create(ctx context.Context) (wire.Response, error) { if e != nil { return qualified, e } - data, e := json.Marshal(bootstrap) + data, e := bootstrap.RuntimeConnection().Marshal() if e != nil { return wire.Response{}, e }