diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bad0d083..5afb1130c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,6 +190,9 @@ Two lifetimes stay separate: Closing a Session Executor does not release its allocation, destroy its Environment or delete its workspace; see [Executor and Turn lifetimes](docs/runtime-protocol.md#executor-and-turn-lifetimes). +Environment preparation state belongs to the Environment, independently of any +managed allocation. Both user-owned and managed machines use the same frozen +preparation input and initializer; resource managers never run installation steps. Capability preparation follows [Environments](contracts/agents-api/environments.md#runtime-capability-preparation). The Runtime is not a sandbox; see [Runtime and outer isolation](docs/design-principles.md#runtime-and-outer-isolation). diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 400565ff7..6d2ef1732 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -123,29 +123,33 @@ _openapi: results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to - enabled; disabled and restricted exact ASCII hostnames are supported. - Restricted policy requires 1–100 allowed domains. Unsupported hostname - forms and startup installations are rejected. Confidential env, - npm/Python packages and ordered setup commands use the shared - initialization lifecycle; requested network applies after setup. - System dependencies must be preinstalled in the sandbox image or - template, or on the host machine; packages.system is rejected. Initial - inline and tenant-owned file_id files freeze encrypted bytes before - provisioning, then install through the common Core lifecycle before - native execution or live Files access. With a template reference, - omitted/null files, env, packages and setup_commands inherit. Non-null - files and command lists replace; env overlays by key; each package - manager inherits on omission/null and otherwise replaces its list. - Empty lists clear their selected field. Tenant-owned - environment_template_id references inherit omitted/null network and - allow only narrowing overrides. Inline hosted network:null retains the - enabled default; updating a Template with network:null resets its - saved policy to enabled. Core freezes effective configuration; - template updates/deletion do not alter Session snapshots or - same-intent creation retries. Inline or tenant-owned skill_reference - Skills share initialization. Templates preserve - default/latest/explicit selectors; Session creation freezes concrete - metadata and encrypted content atomically. Skill, Plugin and + enabled; disabled and restricted policies reject before compute + allocation because the current Runtime cannot enforce them. The + x_agents_core.environment extension accepts common preparation fields + for either hosted or self-hosted placement: environment_template_id, + files, env, packages, setup_commands, skills, plugins and + capability_directories. Duplicate fields in environment and the + extension reject. Confidential env, npm/Python packages and ordered + setup commands use the same Environment-owned initialization + lifecycle; compute allocation does not own preparation. Unknown side + effects are not replayed after disconnect or restart. System + dependencies must be preinstalled in the sandbox image or template, or + on the host machine; packages.system is rejected. Initial inline and + tenant-owned file_id files freeze encrypted bytes before provisioning, + then install through the common Core lifecycle before native execution + or live Files access. With a template reference, omitted/null files, + env, packages and setup_commands inherit. Non-null files and command + lists replace; env overlays by key; each package manager inherits on + omission/null and otherwise replaces its list. Empty lists clear their + selected field. Tenant-owned environment_template_id references + inherit omitted/null network and allow only narrowing overrides. + Inline hosted network:null retains the enabled default; updating a + Template with network:null resets its saved policy to enabled. Core + freezes effective configuration; template updates/deletion do not + alter Session snapshots or same-intent creation retries. Inline or + tenant-owned skill_reference Skills share initialization. Templates + preserve default/latest/explicit selectors; Session creation freezes + concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 1979824a7..770b25532 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -14,7 +14,7 @@ For hosted compute, the deployment selects E2B, Docker or microsandbox through [Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md) provide reusable preparation; the Core extension also applies their execution configuration to user-managed machines; [Files](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created @@ -50,10 +50,10 @@ A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. Omitted/null network defaults to enabled. The daemon does not enforce disabled -or restricted networking. An execution combination must reject unless its outer -Environment implements and qualifies the requested behavior. +or restricted networking. Current execution profiles reject both before Session +creation and resource allocation. Templates and inline configuration share initial files, env, packages, ordered setup -and inline or tenant-owned referenced Skills through ordered managed initialization. +and inline or tenant-owned referenced Skills through ordered Environment initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim @@ -142,8 +142,8 @@ bypass pending input. Function callbacks do not populate Environment installatio 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. Platform support and isolation follow +while new Sessions capture their own sources. Recursive references to the installed snapshot and escaping directory +entries 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, @@ -368,7 +368,7 @@ effects have stopped. See ### Preparation order -Core freezes resource versions, metadata and source selections. Managed +Core freezes resource versions, metadata and source selections. Environment initialization runs in this order, every step over the common `runtime_prepare` exchange with the same daemon: @@ -382,15 +382,56 @@ they never execute Core initialization commands. The common runner uses only neutral Environment/Session identity and a Runtime peer, with no Provider, deployment or OS branch. Harness differences belong to native adapters. -A missing authenticated Runtime connection waits before capability -initialization is claimed; an operation whose effect is unknown is not replayed. -Self-hosted capability directories stay in immutable Environment configuration -and never create a managed initialization row or allocation. - -Self-hosted input accepts only `workspace_directory` and optional local -`capability_directories`, not managed Skill/Plugin archive fields or a hosted -Template. Local directory discovery does not populate the public API-managed -`skills` or `plugins` installation arrays; those describe API-managed uploads. +A missing authenticated Runtime connection waits before initialization is claimed. +The Environment owns durable `pending`, `running`, `complete` or `failed` +initialization state. The leased Worker processes both locations with the same +bounded initializer, independently of Provider maintenance. A running operation +whose process-local owner is lost is failed as unconfirmed; setup is never replayed. +A confirmed failure records only the safe step and exit status. Both failure paths +settle pending input through the common Environment failure transaction. Failure +alone does not destroy compute or remove a workspace. + +The pinned self-hosted input retains `workspace_directory` and optional local +`capability_directories`. For portable preparation, applications may supply +`x_agents_core.environment` at Session creation in either location. This is a Core +extension, not an upstream self-hosted field. It accepts `environment_template_id`, +`files`, `env`, `packages`, `setup_commands`, `skills`, `plugins` and +`capability_directories`, with the same parsers and inheritance rules as hosted +input. A field supplied in both places rejects, including explicit null; there is +no hidden precedence. Machine location, sizing and network policy are not extension +preparation fields. A self-hosted selection cannot use a template requiring a +managed network restriction. + +```json +{ + "agent_id": "agent_example", + "environment": { + "type": "self_hosted", + "workspace_directory": "/home/user/project" + }, + "x_agents_core": { + "environment": { + "environment_template_id": "env_template_example" + } + } +} +``` + +Switching `environment` to `{"type":"openai_hosted"}` reuses that same +preparation input. Resource resolution, Project authorization, concrete Skill +versions, encrypted file contents and confidential tool variables are frozen at +Session creation. Same-intent retries and reconnects reuse those snapshots; +new Sessions resolve new versions. User-managed Environments never require an +allocation record. Local directory discovery does not populate API-managed +installation arrays; managed Skills, Plugins and initial files do, in either +location. Session self-hosted responses retain their pinned connection shape; +the Environment resource exposes the safe installation metadata. + +Transport `connected` is still a connection observation, not readiness. Execution +and live file access wait for initialization, then the common native preparation +owner validates the installed snapshot and Harness before admitting a Turn. The +model-provider authority rule is unchanged: deployment credentials are not +implicitly sent to user-owned machines. ### Transfer and paths @@ -485,17 +526,16 @@ Native installation and validation limits are in the [native guide](/self-hosted-native). Historical acceptance evidence stays limited to its recorded binaries and inputs. -### Allocation initialization lifecycle +### Environment initialization and compute wake -The allocation lifecycle owns pending/running/complete initialization. Authentication -may connect the daemon during initialization; execution bindings, native preparation, -live Files and connected publication wait for completion. Keep Provider bootstrap -settlement distinct. Advance at most one bounded initialization operation per full -maintenance scan. At allocation EOF, begin the next page in the same call rather -than consume an observation interval on an empty page. Refill at most once, retain -the 32-allocation per-call bound and the five-second ticker, and never loop on an -empty store. For managed nodes these bounds apply independently to each node. -Use process-local progress and the same node lifecycle gate as direct provisioning. +Environment initialization has pending, running, complete and failed states. +Authentication and connection publication are independent of preparation. Execution +bindings and live Files wait for initialization. The leased Worker's common +scheduler scans 32 Environments at a time, wraps at EOF and bounds concurrent +preparations by execution concurrency. Missing sockets do not consume a pending +attempt; unavailable Harnesses fail before installation. Each operation rechecks +current authority and the original socket. Completion rechecks the exact binding. +Provider bootstrap and resource cleanup retain their own owners and settlement. After a next-Turn input is durably pending, a completed managed allocation in a suspension/recovery phase may hint this loop. Initial inputs, cold creation, @@ -510,8 +550,9 @@ ownership checks. Never close the hint channel while handlers may still send. This bounds extra maintenance work but does not bypass capacity, a busy lifecycle gate or multi-page scheduling, and does not guarantee a resume deadline. -A recovered or uncertain running installation fails and uses existing cleanup, without replaying writes. -A hosted provisioning failure is terminal for its Session. The allocation's cleanup +A recovered or uncertain running installation fails without replaying writes. +Preparation failure is terminal for its Session. It does not destroy compute or +user files; explicit resource cleanup retains its existing owner. The Environment transaction stores a safe reason with the failed Environment and records `environment.failed`, `error` and one `agent.session.failed`; Session reads derive `failed`, that reason and the failure time from the same record, and live streams end @@ -735,4 +776,15 @@ executor interoperability requirement. The superseded execution route is removed retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. +### Explicit local tool environment + +`--tool-env-file` supplies the Runtime operator's base tool variables. Common +preparation copies these explicit values into its private initialization snapshot; +explicit Session `env` keys override the base. Runtime does not rewrite the source +file or inherit unrelated ambient credentials. Setup, capability resolution and +Harness execution read the same prepared snapshot. Reconnect preserves that +snapshot even if the operator edits the source; a new installation for a new +Session reads the current source. A missing or invalid explicitly configured file +fails preparation. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/runtime-protocol.mdx b/apps/docs/content/docs/runtime-protocol.mdx index a21ce77db..e601f2494 100644 --- a/apps/docs/content/docs/runtime-protocol.mdx +++ b/apps/docs/content/docs/runtime-protocol.mdx @@ -515,4 +515,15 @@ its controlled native subprocess fixture. New adapters can call the same asserti no optional feature is implied. Native failures, uncertain cleanup and exact-history recovery still require the adapter's fault and real-provider acceptance tests. +### Environment preparation ownership + +Core tracks initialization on the Environment, independently of a managed +allocation. After authentication, both managed and user-owned Runtime connections +receive the same `runtime_prepare` operations and resource snapshots. Core never +replays a running initialization whose owner or confirmation was lost. Preparation +failure settles Environment input without destroying the machine or workspace. +The public `connected` state describes transport; initialization completion and +native executor readiness remain separate prerequisites for execution. Input +sources and frozen metadata follow the [Environment contract](/environments-and-files#runtime-capability-preparation). + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-protocol.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index fa21d6eef..5e2917a45 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -75,10 +75,28 @@ environment = { Core accepts absolute Unix, Windows drive and UNC source paths without checking its own filesystem. Runtime validates them using the executor host's path syntax. -Populate these directories before first execution. They are ordinary paths visible +Populate these directories before connecting the Runtime. They are ordinary paths visible to that process; naming a directory does not mount it or create a sandbox. -Self-hosted public input accepts local directories, not managed Skill/Plugin -archives or hosted Templates. +Use `x_agents_core.environment` for the same project-owned Skills, Plugin archives, +files, dependencies, setup commands or Template used by a managed Session: + +```python +session = client.beta.agents.sessions.create( + agent_id=agent_id, + environment=environment, + extra_body={"x_agents_core": { + "model_provider": model_provider, + "environment": {"environment_template_id": template_id}, + }}, +) +``` + +The same extension works with `environment={"type": "openai_hosted"}`. Do not +repeat a field in both `environment` and the extension. The +[preparation contract](/environments-and-files#runtime-capability-preparation) +lists fields, merge rules and failure semantics. Your machine needs the selected +Harness and any required system dependencies; setup uses your account permissions. +Deployment model keys are never sent automatically to a user-managed machine. Runtime snapshots sources before native execution and uses the same parser and `installed.json` format as managed bundles to supply Skills and Plugin MCP. diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx index e21f3adb3..5b3e93889 100644 --- a/apps/docs/content/docs/self-hosted-native.mdx +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -171,4 +171,7 @@ On Windows, stdio MCP commands named npm or npx (including explicit .cmd paths) run through the selected installation's JavaScript entrypoint with Node. Other batch wrappers require an explicit cmd.exe command and its arguments. +For precedence and snapshot behavior of `--tool-env-file` with Session +preparation, see the [Environment contract](/environments-and-files#explicit-local-tool-environment). + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/self-hosted-native.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 2d4e1fb79..855a7536f 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -13,10 +13,10 @@ "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "00979732412a971013e8f01b4c74820ff51aafdded6b0f105d25a78af86a6627", "docs/examples.md": "0e1bdaeff51c9c36779f817be31ea9816b7d8cb2290cf8350d3c4801f436f4f9", - "contracts/agents-api/environments.md": "392b1d39969867f02d7bc7bd238c10211276ac20d679650980264281ff89353e", + "contracts/agents-api/environments.md": "d5fc16c904f751bd13e2af35c9e43c92ca39c6b86401f85cf7f9ee4093c156c9", "docs/getting-started/nodes.md": "c1ad18c445990ea696625011cc3cfa0725779beefd379064d278656801d5578e", - "docs/getting-started/self-hosted.md": "eda02de55559b60dc2e38da3c24a26bfe2be38db386c7d7861ccc921dd26aa51", - "docs/self-hosted-native.md": "1d08e46b8406411f0efeab5940cbda907cd55afb8aec159f5233d2c0ef9d3d54", + "docs/getting-started/self-hosted.md": "5ade597e09cbfa2e321f341693b47730b3696fe7bd4d6834eafbe6b279a55e6b", + "docs/self-hosted-native.md": "b3cf736f88792e6925c50b81a4c33eba6b9ee86e195f9ed4e2187db7bed71d88", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "47159689b2488f0a94a1af8488bcf98b465e0cca4003d64a4699d7b4db24e086", "docs/api/web-management.md": "fe24e883f5745d262d3bd88eb73ae2cbbb723297b9a237f28849f9e3b50dffd4", @@ -27,7 +27,7 @@ "docs/development.md": "f5c340253036a2cabc43e22aecdf70522d90280d6511e7649278ae93712ab8b6", "contracts/agents-api/harness-onboarding.md": "234906652d381f4920d2245a795ad7f12f2f66577018db4dd5d545697d4f111b", "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", - "docs/runtime-protocol.md": "0e968e509236369dd4e840b94d9083372830c45b695ae3a71a56eba5bae01347", + "docs/runtime-protocol.md": "79f6bf1597eb0c82b41074fd21a6617225ffb7d11572c156e1d56c4e8741f322", "docs/sandbox-provider.md": "4d47b234a457f874f3ff7e61a7f5da6fc8b75b0c6df0ce0ce9ead1c07afdfb3e", "apps/docs/scripts/guides.json": "3c3768fb94fd3d42464d4ce8c55724b9ba8360133c7cc19d513d8ec83a8e034f" }, @@ -44,10 +44,10 @@ "content/docs/agents-and-tools.mdx": "b8a85fe92e19a71b7ad7c9357ce7ad0595d50c9190edb748ca834df615b056cb", "content/docs/sessions.mdx": "bb63d799ed90038836d652f3f866d0309822425114c6b2aa36654b8a991947f6", "content/docs/examples.mdx": "587061e65ab2e841d14980539ba94e2216d4e8135c948a852b9a8bb0359c85d7", - "content/docs/environments-and-files.mdx": "83f236b22d241122817e016fdc4794bebb8c713338205b4e5318f9d84786f2e7", + "content/docs/environments-and-files.mdx": "352ca1f2b0ab1b0853b890be694629bb47399ba6bc88905f68feaa3bfe401949", "content/docs/hosted-providers.mdx": "85696d88b76f1233db3d8db3266bb0ab4d297d795a44ede13cf34e5169524c68", - "content/docs/self-hosted-execution.mdx": "df9e886c0430ba2aefba6eb698a8c33e5cd9b4e5474cad72440e5b75cce2c60c", - "content/docs/self-hosted-native.mdx": "b2c5b2bb2080b5338bbaa3469f196555be31089ee713d2b9f1be8d47fa7e29cc", + "content/docs/self-hosted-execution.mdx": "eeed4c6b3646927ccc3c7ac2d20b2c0f9c8a65e42d734310fe3959e016324e57", + "content/docs/self-hosted-native.mdx": "671451580dfb4f5c134221991f68292a4f992008174d23190b1da3742046571f", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "a930ce36035de74f0c2bef71bed067fc2287ba1c70a485758bf100d484f6adfd", "content/docs/admin-api.mdx": "f43f4f1cac887bc4baa4968d76542a87ba1a6ddeef9259299b53cd98a4aea5e1", @@ -58,7 +58,7 @@ "content/docs/development.mdx": "2e5249ddfca571d264e93fd1e0e390a4bd5b0b623bda100cd02f2982929850bb", "content/docs/harness-onboarding.mdx": "d9c9dcbd339ba9b278b133816c1d905bdbd849f44e2ff27882090cd39002e1c8", "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", - "content/docs/runtime-protocol.mdx": "75e7c85a2085cccedf453e8ee751316ba2ee22ed0559e4018ee82df1af805f48", + "content/docs/runtime-protocol.mdx": "fed94a899e953c486d8560a21a2eb15a8c41c173d020e21b57cd207c08c272b7", "content/docs/sandbox-provider.mdx": "9db833fe9ff3f30a65451f80d7251348695a08e7830f9e95871739a710529818" } } diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 0128e1282..bc8e62cfe 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. + description: 'The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage.' parameters: - schema: type: string @@ -5016,6 +5016,9 @@ components: type: object v1.SessionExecutionInput: properties: + environment: + description: Environment supplies placement-independent preparation through the Core extension. + type: object harness_config: type: object model_provider: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index 8a85d8a47..d51f7dfb0 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,6 +1,6 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "57c1b19119010613e80cf926ecc897d2011d8729c9fc6807dbc890d589839ddd", + "contracts/agents-api/openapi.yaml": "270b39eb456c78fd2fff7ca43dee5ae879cd593dec59fc5516a550afcd2df397", "contracts/agents-api/core.openapi.yaml": "f1b9278c3a4b1cb55127cf9471583d00f1ab13b6ceb64f8693a01c4cf03674eb", "contracts/agents-api/runtime.openapi.yaml": "505286d5eacf94a14f9527fcf4e7beb4fba4f792681be26ba966254cbf49b4aa" }, @@ -8,7 +8,7 @@ "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", "content/docs/api-reference/environment-templates.mdx": "68df9276e858e525e15bb88af624fc8adee8bcdcc03a788faaff56b560fd038c", - "content/docs/api-reference/sessions.mdx": "7c12ac70dfdfef86b5d08b9151a7fafdbec6df4848760c81f059b6a331db81a1", + "content/docs/api-reference/sessions.mdx": "91ba66daf03c61cd5abd0d2a2494dff331f6685537cb7783518920be21f3582c", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "fa509794f3c208c0cdcdaa68615c26b7e400ea6c892922a4e0a6a833e42fe5dd", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "a188f3cf63a391b3389a1d9abcb529f7f33651a621fe5a2c18f4c21aea4c523f", + "openapi/public-api.yaml": "dd6f34a59e4dc16aef3e0a48cce44c65b62eb053e8dbbc355fc49d74f9c3c741", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index 13605e761..3e502c3d6 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -36,6 +36,9 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr defer unlock() input := *r.LocalEnvironment.CapabilitySources identity := b.capabilityIdentity() + if err := prepareToolEnvironment(r.LocalEnvironment.ToolEnvironment, marker.completed); err != nil { + return r, err + } manifest, err := b.loadCapabilitySnapshot(root, input, identity, marker.completed) if err != nil || marker.complete() != nil { return r, agentcapabilities.ErrInvalid @@ -116,6 +119,9 @@ func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.Runti case "plugin": err = agentcapabilities.InstallPlugin(root, input.Slot, data, *input.Plugin) case "finalize": + if err := prepareToolEnvironment(false, false); err != nil { + return err + } _, err = b.loadCapabilitySnapshot(root, *input.Sources, b.capabilityIdentity(), false) if err == nil { err = marker.complete() diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go index fbfc83904..365b77f02 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -2,6 +2,7 @@ package localworkspace import ( "context" + "fmt" "os" "path/filepath" "testing" @@ -167,8 +168,8 @@ func TestRuntimePreparationRejectsMissingRequiredToolEnvironment(t *testing.T) { t.Fatal(err) } configured.LocalEnvironment.ToolEnvironment = false - if _, err = b.Prepare(t.Context(), configured); err != nil { - t.Fatal("optional tool environment became mandatory", err) + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("deleted prepared tool environment was silently recreated") } } @@ -178,3 +179,45 @@ func TestRuntimePreparationRejectsMissingExplicitToolEnvironment(t *testing.T) { t.Fatal("missing explicitly configured tool environment ignored") } } + +func TestPreparationFreezesToolOnlyEnvironmentAcrossReconnect(t *testing.T) { + for _, initialFile := range []bool{false, true} { + t.Run(fmt.Sprint(initialFile), func(t *testing.T) { + b, req := testBinding(t) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) + t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", t.TempDir()) + source := filepath.Join(t.TempDir(), "operator.json") + if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"original"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", source) + if initialFile { + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/input"}, []byte("file")); err != nil { + t.Fatal(err) + } + } + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err := b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"changed"}`), 0600); err != nil { + t.Fatal(err) + } + reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace) + if err != nil { + t.Fatal(err) + } + reconnect.networkAccess, reconnect.capabilityRoot = b.networkAccess, b.capabilityRoot + if _, err := reconnect.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + values, err := ReadOptionalToolEnvironment() + if err != nil || values["LOCAL_ONLY"] != "original" { + t.Fatal("reconnect reread mutable source", err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index d147d67b6..c974336a4 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -40,6 +40,10 @@ func ReadToolEnvironment() (map[string]string, error) { if err != nil { return nil, err } + return readToolEnvironmentFile(path) +} + +func readToolEnvironmentFile(path string) (map[string]string, error) { body, err := runtimefs.ReadPrivatePath(path, 1<<20) var values map[string]string if err != nil || json.Unmarshal(body, &values) != nil || values == nil || !validToolEnvironment(values) { @@ -75,16 +79,51 @@ func ReadOptionalToolEnvironment() (map[string]string, error) { } return ReadToolEnvironment() } + +// The prepared snapshot takes precedence over the operator's source file. +// Changing that source after preparation must not change an existing Session. func toolEnvironmentPath() (string, error) { - if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { - if runtimefs.ValidateLocalPath(path) != nil { + path, err := initializedToolEnvironmentPath() + if err != nil { + return "", err + } + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return path, err + } + if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { + if runtimefs.ValidateLocalPath(source) != nil { return "", errors.New("invalid Runtime tool environment file") } - return path, nil + return source, nil } + return path, nil +} + +func initializedToolEnvironmentPath() (string, error) { directory, err := InitializationDirectory() if err != nil { return "", err } return filepath.Join(directory, "tool-env.json"), nil } + +// Freeze local defaults even when Core has no setup operations to send. Once +// the capability snapshot is complete, a missing environment is corruption. +func prepareToolEnvironment(required, completed bool) error { + path, err := initializedToolEnvironmentPath() + if err != nil { + return err + } + if _, err = os.Lstat(path); errors.Is(err, os.ErrNotExist) { + if required || completed { + return errors.New("prepared tool environment unavailable") + } + if err := configureRuntime(nil); err != nil { + return err + } + } else if err != nil { + return err + } + _, err = readToolEnvironmentFile(path) + return err +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go index 6f3925aa4..b71ed6b97 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go @@ -105,7 +105,7 @@ func configureRuntime(values map[string]string) error { if !validToolEnvironment(values) { return agentcapabilities.ErrInvalid } - path, err := toolEnvironmentPath() + path, err := initializedToolEnvironmentPath() if err != nil { return &InitializationFailure{} } @@ -130,6 +130,22 @@ func configureRuntime(values map[string]string) error { return &InitializationFailure{} } configured := make(map[string]string, len(values)+2) + if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { + if runtimefs.ValidateLocalPath(source) != nil { + return &InitializationFailure{} + } + local, err := readToolEnvironmentFile(source) + if err != nil { + return &InitializationFailure{} + } + for key, value := range local { + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + configured[key] = value + } + } + // Explicit Session values override the operator's base tool configuration. for key, value := range values { if runtime.GOOS == "windows" { key = strings.ToUpper(key) diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go index ac8c6246d..916634410 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go @@ -183,9 +183,10 @@ func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { } func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { - initializationFixture(t) + b := initializationFixture(t) file := filepath.Join(t.TempDir(), "explicit.json") - if err := os.WriteFile(file, []byte(`{"DECLARED":"value"}`), 0600); err != nil { + original := []byte(`{"DECLARED":"value","OVERRIDE":"local"}`) + if err := os.WriteFile(file, original, 0600); err != nil { t.Fatal(err) } t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) @@ -193,6 +194,38 @@ func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { if err != nil || env["DECLARED"] != "value" { t.Fatal("explicit tool environment", err) } + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OVERRIDE": "session"}}); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(file); err != nil || string(raw) != string(original) { + t.Fatal("operator source was modified", err) + } + // Reconnect reads the frozen result even after the operator edits its source. + if err := os.WriteFile(file, []byte(`{"DECLARED":"changed"}`), 0600); err != nil { + t.Fatal(err) + } + env, err = ReadOptionalToolEnvironment() + if err != nil || env["DECLARED"] != "value" || env["OVERRIDE"] != "session" { + t.Fatal("tool snapshot was not frozen", err) + } + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { + t.Fatal("configuration replay succeeded") + } +} + +func TestRuntimeInitializationRejectsMissingExplicitToolEnvironment(t *testing.T) { + b := initializationFixture(t) + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { + t.Fatal("missing explicit environment was ignored") + } + path, err := initializedToolEnvironmentPath() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("failed configuration left a prepared snapshot") + } } func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index a26736c8d..6ad43c2fb 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -442,7 +442,10 @@ upgrade the protocol. exact hosted error semantics remain gaps. [Environment Templates](environment-templates.md) provide tenant-owned CRUD/list -and immutable Session resolution through the same hosted initialization. They do not +and immutable Session resolution through common Environment preparation. +The `x_agents_core.environment` extension supplies that configuration to either +placement; self-hosted machines never need a managed allocation. See +[shared preparation qualification](environment-preparation-qualification.md). They do not select an E2B image or make unsupported initialization executable. The supported Docker configuration has [composed real acceptance](environment-templates.md#composed-initialization-acceptance) across the three harnesses, including frozen source deletion, cold continuation diff --git a/contracts/agents-api/environment-preparation-qualification.md b/contracts/agents-api/environment-preparation-qualification.md new file mode 100644 index 000000000..f2a8dccfd --- /dev/null +++ b/contracts/agents-api/environment-preparation-qualification.md @@ -0,0 +1,59 @@ +# Shared Environment preparation qualification + +This record covers the shared preparation change based on Core +`5cac06c13eccee954575a359df9b014d728f4d14`. The current behavioral contract is +[Environments](environments.md); this record does not broaden Harness capabilities. + +## Native execution + +Every passing row used the pinned official Python SDK, a real Core and PostgreSQL, +the daemon and the native Harness against MiniMax-M2.7. Model credentials were +supplied explicitly by each Session and excluded from retained public evidence. + +| Environment | Harness | Verified | +| --- | --- | --- | +| Linux user machine | Codex 0.153.4 | Uploaded Skill script, Plugin stdio MCP, initial file, setup, two Turns and daemon restart | +| Linux user machine | Claude SDK 0.3.269 | Same workload, two Turns and daemon restart | +| Linux user machine | MiniMax Code 0.4.12 | Same workload and declared workspace, two Turns and daemon restart | +| Docker managed Linux | Codex 0.153.4 | Same preparation input and workload, two Turns and container restart | +| Native macOS arm64 | Codex 0.153.4 | Same workload, physical workspace, two Turns and daemon restart | + +Skill execution wrote a marker loaded from the installed archive; a separate MCP +call returned another marker. Tests checked the resulting workspace files and +hashes of the installed capability tree independently of model text. Editing the +Template after preparation left existing Sessions unchanged; new Sessions read the +edited setup command. No allocation was fabricated for user-managed machines. + +Additional Linux Codex runs exercised an explicit operator tool-environment file: +local defaults, a Session override, preserving the source, editing the source and +reconnecting with the original values. Separate empty-configuration and initial-file-only +Sessions also retained their original tool variables after restart. + +## Contract and regression coverage + +Focused tests cover the shared extension and Template parser, encrypted frozen +resources and tenant access, authenticated preparation without an allocation, +missing Harnesses, failed or unknown steps, revoked or stale bindings, recovery +without replay, independent progress on healthy nodes and unchanged typed client +requests. Runtime tests cover local configuration freezing, missing snapshot +rejection, capability integrity and the existing Harness adapters. + +Required repository checks and native CI results are recorded on the change's PR. +The remote browser checks use cached Chromium instead of the local Google Chrome +channel; they run the same acceptance cases. No large build runs on the Mac. + +## Limits + +These probes do not establish complete upstream parity or qualify new image, +structured-output, tool-discovery or public HTTP MCP combinations. Those require +separate adapter changes and qualification. The shared setup/package mechanisms +retain their contract tests; these live probes do not download npm/Python packages. +Windows requires its native CI job; no Windows manual execution was performed. +No Claude installation was performed on the Mac. E2B and microsandbox were not +requalified by this change. + +An initial managed probe used a cached image with retired Codex requirements; +native shell execution failed. The passing managed row used a clean image without +those requirements. An initial MiniMax probe exposed a wrong native working +directory; the passing row includes the separately merged workspace fix. Neither +failed attempt is counted as acceptance. diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index a8635a950..27000f88c 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -79,23 +79,22 @@ Core initializes both paths through the authenticated daemon's typed `runtime_pr file operation. The Runtime resolves the logical workspace path and owns the trusted file installer. Daemon authentication remains available, but native preparation and live Files wait for all writes. Each file gets a two-minute transfer budget; the batch has -a thirty-minute local budget and shares maintenance scans with other allocations. +a thirty-minute local budget in the common Environment preparation scheduler. These are local operational limits, not verified upstream timing. Initial input retains its existing five-minute admission deadline; large installations can use an -idle Session and wait for connected status before submitting input. +idle Session; connection status alone does not establish preparation readiness. -Uncertain writes and Core restart during initialization fail the new Environment and -reclaim it; they do not replay partial installation. After completion, reconnect and +Uncertain writes and Core restart during initialization fail the new Environment while retaining compute ownership and files; they do not replay partial installation. After completion, reconnect and native-history recovery preserve user modifications instead of reinstalling files. -Current Core-hosted Docker and all three harnesses use this lifecycle. The Provider +Managed and user-owned Runtime connections use this lifecycle. The Provider API remains five operations; public Templates are never E2B image templates and -remain hosted-only. E2B now uses user-managed Runtime enrollment through the official +are also available to self-hosted Sessions through `x_agents_core.environment`. E2B now uses user-managed Runtime enrollment through the official SDK. Historical Core-managed E2B evidence below retains its original scope and does not qualify that new chain. ## Skills and versioned references -Both templates and standalone hosted configuration accept project-owned Skill +Both templates and standalone preparation configuration accept project-owned Skill references and inline Skill ZIPs. Upload a directory through the pinned SDK, then reference its default version from a template: diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 2fb5989a6..0b3f47e86 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -11,7 +11,7 @@ For hosted compute, the deployment selects E2B, Docker or microsandbox through [Hosted Sandbox Manager](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and cleanup through the official SDK and [Runtime packaging](../../services/agents-api/deploy/e2b/README.md). -[Templates](environment-templates.md) remain a hosted-only resource path; +[Templates](environment-templates.md) provide reusable preparation; the Core extension also applies their execution configuration to user-managed machines; [Files](environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created @@ -47,10 +47,10 @@ A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. Omitted/null network defaults to enabled. The daemon does not enforce disabled -or restricted networking. An execution combination must reject unless its outer -Environment implements and qualifies the requested behavior. +or restricted networking. Current execution profiles reject both before Session +creation and resource allocation. Templates and inline configuration share initial files, env, packages, ordered setup -and inline or tenant-owned referenced Skills through ordered managed initialization. +and inline or tenant-owned referenced Skills through ordered Environment initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim @@ -139,8 +139,8 @@ bypass pending input. Function callbacks do not populate Environment installatio 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. Platform support and isolation follow +while new Sessions capture their own sources. Recursive references to the installed snapshot and escaping directory +entries 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, @@ -365,7 +365,7 @@ effects have stopped. See ### Preparation order -Core freezes resource versions, metadata and source selections. Managed +Core freezes resource versions, metadata and source selections. Environment initialization runs in this order, every step over the common `runtime_prepare` exchange with the same daemon: @@ -379,15 +379,56 @@ they never execute Core initialization commands. The common runner uses only neutral Environment/Session identity and a Runtime peer, with no Provider, deployment or OS branch. Harness differences belong to native adapters. -A missing authenticated Runtime connection waits before capability -initialization is claimed; an operation whose effect is unknown is not replayed. -Self-hosted capability directories stay in immutable Environment configuration -and never create a managed initialization row or allocation. - -Self-hosted input accepts only `workspace_directory` and optional local -`capability_directories`, not managed Skill/Plugin archive fields or a hosted -Template. Local directory discovery does not populate the public API-managed -`skills` or `plugins` installation arrays; those describe API-managed uploads. +A missing authenticated Runtime connection waits before initialization is claimed. +The Environment owns durable `pending`, `running`, `complete` or `failed` +initialization state. The leased Worker processes both locations with the same +bounded initializer, independently of Provider maintenance. A running operation +whose process-local owner is lost is failed as unconfirmed; setup is never replayed. +A confirmed failure records only the safe step and exit status. Both failure paths +settle pending input through the common Environment failure transaction. Failure +alone does not destroy compute or remove a workspace. + +The pinned self-hosted input retains `workspace_directory` and optional local +`capability_directories`. For portable preparation, applications may supply +`x_agents_core.environment` at Session creation in either location. This is a Core +extension, not an upstream self-hosted field. It accepts `environment_template_id`, +`files`, `env`, `packages`, `setup_commands`, `skills`, `plugins` and +`capability_directories`, with the same parsers and inheritance rules as hosted +input. A field supplied in both places rejects, including explicit null; there is +no hidden precedence. Machine location, sizing and network policy are not extension +preparation fields. A self-hosted selection cannot use a template requiring a +managed network restriction. + +```json +{ + "agent_id": "agent_example", + "environment": { + "type": "self_hosted", + "workspace_directory": "/home/user/project" + }, + "x_agents_core": { + "environment": { + "environment_template_id": "env_template_example" + } + } +} +``` + +Switching `environment` to `{"type":"openai_hosted"}` reuses that same +preparation input. Resource resolution, Project authorization, concrete Skill +versions, encrypted file contents and confidential tool variables are frozen at +Session creation. Same-intent retries and reconnects reuse those snapshots; +new Sessions resolve new versions. User-managed Environments never require an +allocation record. Local directory discovery does not populate API-managed +installation arrays; managed Skills, Plugins and initial files do, in either +location. Session self-hosted responses retain their pinned connection shape; +the Environment resource exposes the safe installation metadata. + +Transport `connected` is still a connection observation, not readiness. Execution +and live file access wait for initialization, then the common native preparation +owner validates the installed snapshot and Harness before admitting a Turn. The +model-provider authority rule is unchanged: deployment credentials are not +implicitly sent to user-owned machines. ### Transfer and paths @@ -482,17 +523,16 @@ Native installation and validation limits are in the [native guide](../../docs/self-hosted-native.md). Historical acceptance evidence stays limited to its recorded binaries and inputs. -### Allocation initialization lifecycle +### Environment initialization and compute wake -The allocation lifecycle owns pending/running/complete initialization. Authentication -may connect the daemon during initialization; execution bindings, native preparation, -live Files and connected publication wait for completion. Keep Provider bootstrap -settlement distinct. Advance at most one bounded initialization operation per full -maintenance scan. At allocation EOF, begin the next page in the same call rather -than consume an observation interval on an empty page. Refill at most once, retain -the 32-allocation per-call bound and the five-second ticker, and never loop on an -empty store. For managed nodes these bounds apply independently to each node. -Use process-local progress and the same node lifecycle gate as direct provisioning. +Environment initialization has pending, running, complete and failed states. +Authentication and connection publication are independent of preparation. Execution +bindings and live Files wait for initialization. The leased Worker's common +scheduler scans 32 Environments at a time, wraps at EOF and bounds concurrent +preparations by execution concurrency. Missing sockets do not consume a pending +attempt; unavailable Harnesses fail before installation. Each operation rechecks +current authority and the original socket. Completion rechecks the exact binding. +Provider bootstrap and resource cleanup retain their own owners and settlement. After a next-Turn input is durably pending, a completed managed allocation in a suspension/recovery phase may hint this loop. Initial inputs, cold creation, @@ -507,8 +547,9 @@ ownership checks. Never close the hint channel while handlers may still send. This bounds extra maintenance work but does not bypass capacity, a busy lifecycle gate or multi-page scheduling, and does not guarantee a resume deadline. -A recovered or uncertain running installation fails and uses existing cleanup, without replaying writes. -A hosted provisioning failure is terminal for its Session. The allocation's cleanup +A recovered or uncertain running installation fails without replaying writes. +Preparation failure is terminal for its Session. It does not destroy compute or +user files; explicit resource cleanup retains its existing owner. The Environment transaction stores a safe reason with the failed Environment and records `environment.failed`, `error` and one `agent.session.failed`; Session reads derive `failed`, that reason and the failure time from the same record, and live streams end @@ -731,3 +772,14 @@ forwarding. The explicit daemon-executor decision supersedes the previous native executor interoperability requirement. The superseded execution route is removed; retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. + +### Explicit local tool environment + +`--tool-env-file` supplies the Runtime operator's base tool variables. Common +preparation copies these explicit values into its private initialization snapshot; +explicit Session `env` keys override the base. Runtime does not rewrite the source +file or inherit unrelated ambient credentials. Setup, capability resolution and +Harness execution read the same prepared snapshot. Reconnect preserves that +snapshot even if the operator edits the source; a new installation for a new +Session reads the current source. A missing or invalid explicitly configured file +fails preparation. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index ea334ef7d..b3d283ae6 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1505,6 +1505,10 @@ definitions: type: object v1.SessionExecutionInput: properties: + environment: + description: Environment supplies placement-independent preparation through + the Core extension. + type: object harness_config: type: object model_provider: @@ -2982,7 +2986,7 @@ paths: post: consumes: - application/json - description: The optional Core model_provider bundle resolves from the Session + description: 'The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve @@ -3070,17 +3074,21 @@ paths: non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. - Network defaults to enabled; disabled and restricted exact ASCII hostnames - are supported. Restricted policy requires 1–100 allowed domains. Unsupported - hostname forms and startup installations are rejected. Confidential env, npm/Python - packages and ordered setup commands use the shared initialization lifecycle; - requested network applies after setup. System dependencies must be preinstalled - in the sandbox image or template, or on the host machine; packages.system - is rejected. Initial inline and tenant-owned file_id files freeze encrypted - bytes before provisioning, then install through the common Core lifecycle - before native execution or live Files access. With a template reference, omitted/null - files, env, packages and setup_commands inherit. Non-null files and command - lists replace; env overlays by key; each package manager inherits on omission/null + Network defaults to enabled; disabled and restricted policies reject before + compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment + extension accepts common preparation fields for either hosted or self-hosted + placement: environment_template_id, files, env, packages, setup_commands, + skills, plugins and capability_directories. Duplicate fields in environment + and the extension reject. Confidential env, npm/Python packages and ordered + setup commands use the same Environment-owned initialization lifecycle; compute + allocation does not own preparation. Unknown side effects are not replayed + after disconnect or restart. System dependencies must be preinstalled in the + sandbox image or template, or on the host machine; packages.system is rejected. + Initial inline and tenant-owned file_id files freeze encrypted bytes before + provisioning, then install through the common Core lifecycle before native + execution or live Files access. With a template reference, omitted/null files, + env, packages and setup_commands inherit. Non-null files and command lists + replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; @@ -3100,7 +3108,7 @@ paths: Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on - behavior. Other combinations remain unqualified; see the operation coverage. + behavior. Other combinations remain unqualified; see the operation coverage.' parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 7fe2f6582..7bef7f43f 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -78,7 +78,10 @@ retention, tells roughly when Core reclaims it. The official `openai_hosted` discriminator means hosting by this independent Core service, using its configured hosted Provider. Keep the public value unchanged; a product-named hosted value is not -a new API type. Public Environment Templates apply only to this hosted path. +a new API type. Both placements resolve the same Environment Templates: hosted +requests use the official field, and self-hosted requests use +`x_agents_core.environment.environment_template_id`. See +[Environment preparation](environments.md) for the shared snapshot contract. E2B onboarding follows the application-managed `self_hosted` resource workflow: the application owns sandbox provisioning and cleanup, and our daemon connects with the returned Environment ID, unchanged `remote_url` and scoped environment diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 1686d5a1b..95db49fd9 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -19,6 +19,8 @@ func (e *ModelProviderError) Error() string { return e.message } // SessionExecutionInput is a write-only execution extension, not a provider resource. type SessionExecutionInput struct { + // Environment supplies placement-independent preparation through the Core extension. + Environment json.RawMessage `json:"environment,omitempty" swaggertype:"object"` ModelProvider *ModelProviderInput `json:"model_provider,omitempty"` HarnessConfig json.RawMessage `json:"harness_config,omitempty" swaggertype:"object"` } diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go index 59efa3b1e..d5b7d6395 100644 --- a/contracts/agents-api/v1/upstream_contract_test.go +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -101,7 +101,7 @@ var listEnvelopeFields = []string{"object", "first_id", "last_id"} // definitions, and only as these members. var ( coreExtensionOwners = []string{"v1.Agent", "v1.InlineAgent", "v1.SavedAgent", "v1.CreateAgentRequest", "v1.UpdateAgentRequest", "v1.Session", "v1.CreateSessionRequest"} - coreExtensionMembers = []string{"harness", "model_provider", "harness_config"} + coreExtensionMembers = []string{"harness", "model_provider", "harness_config", "environment"} ) type upstreamFields struct { diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index b039dbded..6dddae568 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -72,10 +72,28 @@ environment = { Core accepts absolute Unix, Windows drive and UNC source paths without checking its own filesystem. Runtime validates them using the executor host's path syntax. -Populate these directories before first execution. They are ordinary paths visible +Populate these directories before connecting the Runtime. They are ordinary paths visible to that process; naming a directory does not mount it or create a sandbox. -Self-hosted public input accepts local directories, not managed Skill/Plugin -archives or hosted Templates. +Use `x_agents_core.environment` for the same project-owned Skills, Plugin archives, +files, dependencies, setup commands or Template used by a managed Session: + +```python +session = client.beta.agents.sessions.create( + agent_id=agent_id, + environment=environment, + extra_body={"x_agents_core": { + "model_provider": model_provider, + "environment": {"environment_template_id": template_id}, + }}, +) +``` + +The same extension works with `environment={"type": "openai_hosted"}`. Do not +repeat a field in both `environment` and the extension. The +[preparation contract](../../contracts/agents-api/environments.md#runtime-capability-preparation) +lists fields, merge rules and failure semantics. Your machine needs the selected +Harness and any required system dependencies; setup uses your account permissions. +Deployment model keys are never sent automatically to a user-managed machine. Runtime snapshots sources before native execution and uses the same parser and `installed.json` format as managed bundles to supply Skills and Plugin MCP. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 1a572ad0b..480765419 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -511,3 +511,14 @@ 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. + +### Environment preparation ownership + +Core tracks initialization on the Environment, independently of a managed +allocation. After authentication, both managed and user-owned Runtime connections +receive the same `runtime_prepare` operations and resource snapshots. Core never +replays a running initialization whose owner or confirmation was lost. Preparation +failure settles Environment input without destroying the machine or workspace. +The public `connected` state describes transport; initialization completion and +native executor readiness remain separate prerequisites for execution. Input +sources and frozen metadata follow the [Environment contract](../contracts/agents-api/environments.md#runtime-capability-preparation). diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index 00ab3b335..647088810 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -167,3 +167,6 @@ dependencies before launch. Official API read responses retain the required On Windows, stdio MCP commands named npm or npx (including explicit .cmd paths) run through the selected installation's JavaScript entrypoint with Node. Other batch wrappers require an explicit cmd.exe command and its arguments. + +For precedence and snapshot behavior of `--tool-env-file` with Session +preparation, see the [Environment contract](../contracts/agents-api/environments.md#explicit-local-tool-environment). diff --git a/packages/agents-client/src/environment-preparation.test.ts b/packages/agents-client/src/environment-preparation.test.ts new file mode 100644 index 000000000..37b045702 --- /dev/null +++ b/packages/agents-client/src/environment-preparation.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import type { CreateSessionInput, EnvironmentPreparationInput } from "./types"; +import { OpenAIAgentsClient } from "./client"; + +it("sends the complete shared preparation input unchanged", async () => { + const preparation: EnvironmentPreparationInput = { + environment_template_id: "template", + env: { EXPLICIT: "value" }, + files: [{ type: "inline", path: "/workspace/input", data: "eA==" }], + packages: { npm: ["is-number@7.0.0"], python: null }, + setup_commands: [{ command: "printf ready", cwd: "/workspace" }], + skills: [{ type: "skill_reference", skill_id: "skill", version: null }], + plugins: [{ type: "inline", name: "plugin", description: "proof", source: { type: "base64", media_type: "application/zip", data: "eA==" } }], + capability_directories: ["/home/user/capabilities"], + }; + for (const environment of [{ type: "self_hosted", workspace_directory: "/home/user/work" }, { type: "openai_hosted" }] as const) { + let submitted: unknown; + const client = new OpenAIAgentsClient({ fetch: async (_url, options) => { + submitted = JSON.parse(String(options?.body)); + return new Response(JSON.stringify({ error: { message: "test admission", type: "invalid_request_error", code: "invalid_request_error", param: null } }), { status: 400, headers: { "Content-Type": "application/json" } }); + } }); + const input: CreateSessionInput = { agent_id: "agent", environment, x_agents_core: { environment: preparation } }; + await expect(client.createSession(input)).rejects.toThrow(); + expect(submitted).toEqual(input); + } +}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index ad21d1ff4..56346dee0 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -722,8 +722,27 @@ export interface TolerantSessionList { last_id: string | null; } +/** Common preparation for both managed and user-owned Runtime locations. */ +export interface EnvironmentCapabilityArchiveInput { + type: "inline"; + name: string; + description: string; + source: { type: "base64"; media_type: "application/zip"; data: string }; +} + +export interface EnvironmentPreparationInput { + environment_template_id?: string; + env?: Record | null; + files?: EnvironmentFileCreateInput[] | null; + packages?: { npm?: string[] | null; python?: string[] | null } | null; + setup_commands?: { command: string; cwd?: string }[] | null; + skills?: (EnvironmentCapabilityArchiveInput | { type: "skill_reference"; skill_id: string; version?: string | null })[] | null; + plugins?: EnvironmentCapabilityArchiveInput[] | null; + capability_directories?: string[] | null; +} + export interface CreateSessionInput { - x_agents_core?: { model_provider?: ModelProviderInput | null; harness_config?: HarnessConfig }; + x_agents_core?: { model_provider?: ModelProviderInput | null; harness_config?: HarnessConfig; environment?: EnvironmentPreparationInput }; agent_id?: string; agent?: InlineAgentInput; environment: AgentEnvironmentInput; diff --git a/services/agents-api/IMPLEMENTATION.md b/services/agents-api/IMPLEMENTATION.md index 6c50a2d22..99738e862 100644 --- a/services/agents-api/IMPLEMENTATION.md +++ b/services/agents-api/IMPLEMENTATION.md @@ -142,6 +142,17 @@ Exporter component checks do not establish public Artifact compatibility. ## Dispatch and pending input +Environment initialization is owned by the leased Worker's common preparation +scheduler, independently of any RuntimeAllocation. Both managed and enrolled +connections use the same frozen files/setup/capability snapshots and typed Runtime +operations. Preparation has bounded concurrency separate from Turn scheduling; +a blocked Runtime must not block resource cleanup or other connections. Check +Harness availability before installing. A persisted running initialization whose +owner is lost fails without replaying side effects. Completion requires the same +authorized Environment/device binding. Failure settles pending input and preserves +compute ownership and user files. Connection observations do not imply completion. + + Successful input commits send a coalesced hint to the existing Worker scheduler. The scheduler keeps lease, capacity, cursor fairness and per-Session ownership checks; a hint does not admit work itself. If capacity is occupied, preserve one @@ -1394,7 +1405,7 @@ unconfirmed resources; keep these separate from the lifecycle and do not invent a successful running observation after a host restart. Managed lifecycle state is owned by one serial worker per registered node: -its gate, allocation and pending cursors, connections, initialization progress and +its gate, allocation and pending cursors, connections and wake hints are not shared with other nodes. A thin coordinator discovers nodes and owns worker shutdown; it never holds its map mutex during database, provider or wait operations. Each worker advances independently, including when another diff --git a/services/agents-api/internal/api/environment_preparation_input.go b/services/agents-api/internal/api/environment_preparation_input.go new file mode 100644 index 000000000..55807bae8 --- /dev/null +++ b/services/agents-api/internal/api/environment_preparation_input.go @@ -0,0 +1,42 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// The official placement union and the Core extension converge before resource +// resolution. All confidential fields use the existing encrypted setup snapshot. +func preparationEnvironmentInput(raw json.RawMessage, extension *v1.SessionExecutionInput) (json.RawMessage, error) { + // Validate the pinned input before admitting any extension fields. + if _, _, _, err := decodeTemplateEnvironment(raw); err != nil { + return nil, err + } + if extension == nil || len(extension.Environment) == 0 || bytes.Equal(bytes.TrimSpace(extension.Environment), []byte("null")) { + return raw, nil + } + var fields, setup map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(extension.Environment, &setup) != nil || setup == nil { + return nil, store.ErrInvalidInput + } + var kind string + if json.Unmarshal(fields["type"], &kind) != nil || (kind != "openai_hosted" && kind != "self_hosted") { + return nil, store.ErrInvalidInput + } + for name, value := range setup { + switch name { + case "environment_template_id", "files", "env", "packages", "setup_commands", "skills", "plugins", "capability_directories": + default: + return nil, store.ErrInvalidInput + } + // Duplicate sources are ambiguous, including an explicit null. + if _, exists := fields[name]; exists { + return nil, store.ErrInvalidInput + } + fields[name] = value + } + return json.Marshal(fields) +} diff --git a/services/agents-api/internal/api/environment_preparation_input_test.go b/services/agents-api/internal/api/environment_preparation_input_test.go new file mode 100644 index 000000000..2c693bbd2 --- /dev/null +++ b/services/agents-api/internal/api/environment_preparation_input_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "reflect" + "testing" +) + +func TestPreparationExtensionUsesOneConfiguration(t *testing.T) { + setup := `{"env":{"EXPLICIT":"private"},"packages":{"npm":["is-number@7.0.0"]},"setup_commands":[{"command":"touch proof","cwd":"/workspace"}],"files":[{"type":"inline","path":"/workspace/input.txt","data":"aGVsbG8="}],"capability_directories":["/workspace/generated"]}` + var snapshots []sessionRequest + for _, environment := range []string{`{"type":"openai_hosted"}`, `{"type":"self_hosted","workspace_directory":"/home/user/work"}`} { + var request decodedSessionRequest + if err := json.Unmarshal([]byte(`{"environment":`+environment+`,"x_agents_core":{"environment":`+setup+`}}`), &request); err != nil { + t.Fatal(err) + } + got, err := request.validated() + if err != nil { + t.Fatal(err) + } + snapshots = append(snapshots, got) + } + if !reflect.DeepEqual(snapshots[0].initialization, snapshots[1].initialization) || !reflect.DeepEqual(snapshots[0].initialFiles, snapshots[1].initialFiles) { + t.Fatal("placement changed preparation") + } + if snapshots[1].Environment.WorkspaceDirectory != "/home/user/work" { + t.Fatal("user workspace changed") + } + raw, err := json.Marshal(snapshots[1].Environment) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + if fields["env"] != nil || fields["setup_commands"] != nil { + t.Fatal("confidential setup entered public snapshot") + } +} + +func TestPreparationExtensionRejectsAmbiguousAndPlacementInput(t *testing.T) { + for _, input := range []string{ + `{"environment":{"type":"none"},"x_agents_core":{"environment":{"skills":[]}}}`, + `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"network":{"access":"disabled"}}}}`, + `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"workspace_directory":"/other"}}}`, + `{"environment":{"type":"self_hosted","workspace_directory":"/work","capability_directories":[]},"x_agents_core":{"environment":{"capability_directories":[]}}}`, + `{"environment":{"type":"openai_hosted","env":null},"x_agents_core":{"environment":{"env":{"KEY":"value"}}}}`, + `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"packages":{"system":[]}}}}`, + } { + var request decodedSessionRequest + if err := json.Unmarshal([]byte(input), &request); err != nil { + t.Fatal(err) + } + if _, err := request.validated(); err == nil { + t.Fatal("invalid extension accepted", input) + } + } +} + +func TestSelfHostedTemplateUsesExtension(t *testing.T) { + var request decodedSessionRequest + _ = json.Unmarshal([]byte(`{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"environment_template_id":"template-id"}}}`), &request) + value, err := request.validated() + if err != nil || value.templateID != "template-id" || value.Environment.Type != "self_hosted" { + t.Fatal(value.templateID, err) + } + if _, _, _, err := decodeTemplateEnvironment(json.RawMessage(`{"type":"self_hosted","workspace_directory":"/work","environment_template_id":"template-id"}`)); err == nil { + t.Fatal("extension presented as an official field") + } +} + +func TestPreparationTemplateSharedAcrossPlacements(t *testing.T) { + for _, fields := range []string{"", `,"env":{"SHARED":"replacement"},"files":[],"packages":{"npm":[]}`, `,"env":null,"setup_commands":null`} { + hosted := compositionRequest(t, fields) + var request decodedSessionRequest + raw := `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"environment_template_id":"saved"` + fields + `}}}` + if err := json.Unmarshal([]byte(raw), &request); err != nil { + t.Fatal(err) + } + own, err := request.validated() + if err != nil { + t.Fatal(err) + } + h := Handler{store: compositionFixture()} + for _, input := range []*sessionRequest{&hosted, &own} { + if err := h.resolveTemplateEnvironment(t.Context(), "tenant", input); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(hosted.initialization, own.initialization) || !reflect.DeepEqual(hosted.initialFiles, own.initialFiles) { + t.Fatal("template preparation changed with placement") + } + } +} + +func TestPreparationExtensionCreationIntentDoesNotDuplicateFiles(t *testing.T) { + data := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 4<<20)) + for _, placement := range []string{"openai_hosted", "self_hosted"} { + t.Run(placement, func(t *testing.T) { + environment := map[string]any{"type": placement} + if placement == "self_hosted" { + environment["workspace_directory"] = "/work" + } + raw, err := json.Marshal(map[string]any{"environment": environment, "x_agents_core": map[string]any{"environment": map[string]any{"files": []any{ + map[string]any{"type": "inline", "path": "/workspace/a.txt", "data": data}, + map[string]any{"type": "inline", "path": "/workspace/b.txt", "data": data}, + }}}}) + if err != nil { + t.Fatal(err) + } + var request decodedSessionRequest + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(input, nil) + if err != nil { + t.Fatal(err) + } + // Store hashes creation intent under the same 16 MiB request budget. + if len(intent) > 16<<20 || bytes.Count(intent, []byte(data)) != 2 { + t.Fatalf("legal file request was duplicated: input=%d, intent=%d", len(raw), len(intent)) + } + if len(input.initialFiles) != 2 { + t.Fatal("files no longer reach Runtime preparation") + } + input.XAgentsCore.Environment = json.RawMessage(`{"files":[]}`) + changed, err := sessionCreationRequest(input, nil) + if err != nil || bytes.Equal(intent, changed) { + t.Fatal("changed caller intent lost retry identity", err) + } + }) + } +} diff --git a/services/agents-api/internal/api/environment_request_test.go b/services/agents-api/internal/api/environment_request_test.go index 330579d63..cda6d3af8 100644 --- a/services/agents-api/internal/api/environment_request_test.go +++ b/services/agents-api/internal/api/environment_request_test.go @@ -5,14 +5,14 @@ import ( "testing" ) -func TestSelfHostedPathsDoNotCreateManagedInitialization(t *testing.T) { +func TestSelfHostedPathsUseCommonInitialization(t *testing.T) { raw := json.RawMessage(`{"type":"self_hosted","workspace_directory":"/home/user/work","capability_directories":["/opt/skills","/home/user/plugins"]}`) request, err := (decodedSessionRequest{Environment: raw}).validated() if err != nil { t.Fatal(err) } - if !request.initialization.Empty() || request.Environment.WorkspaceDirectory != "/home/user/work" || len(request.Environment.CapabilityDirectories) != 2 { - t.Fatal("local selections became managed initialization", request.initialization, request.Environment) + if len(request.initialization.CapabilityDirectories) != len(request.Environment.CapabilityDirectories) || request.Environment.WorkspaceDirectory != "/home/user/work" || len(request.Environment.CapabilityDirectories) != 2 { + t.Fatal("local selections missing from common initialization", request.initialization, request.Environment) } for _, raw := range []string{ `{"type":"self_hosted","workspace_directory":"/a/../b"}`, @@ -32,7 +32,7 @@ func TestSelfHostedSourcePathsArePlatformNeutral(t *testing.T) { for _, directory := range []string{`C:\work`, `D:/skills`, `\\server\share\project`, `/Users/user/work`} { raw, _ := json.Marshal(map[string]any{"type": "self_hosted", "workspace_directory": directory, "capability_directories": []string{directory}}) request, err := (decodedSessionRequest{Environment: raw}).validated() - if err != nil || request.Environment.WorkspaceDirectory != directory || !request.initialization.Empty() { + if err != nil || request.Environment.WorkspaceDirectory != directory || len(request.initialization.CapabilityDirectories) != len(request.Environment.CapabilityDirectories) { t.Fatal("Core interpreted a Runtime source path", directory, err) } } diff --git a/services/agents-api/internal/api/environments_test.go b/services/agents-api/internal/api/environments_test.go index 22801539b..2639c0307 100644 --- a/services/agents-api/internal/api/environments_test.go +++ b/services/agents-api/internal/api/environments_test.go @@ -91,8 +91,8 @@ func TestEnvironmentResourceRejectsUnknownInventoryAndInvalidState(t *testing.T) "hosted": `{"type":"openai_hosted","env":{"SECRET":"private-canary"}}`, "unclean directories": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":["/skills/../private"]}`, "files": `{"type":"self_hosted","workspace_directory":"/workspace","files":[{"data":"private-canary"}]}`, - "plugins": `{"type":"self_hosted","workspace_directory":"/workspace","plugins":[]}`, - "skills": `{"type":"self_hosted","workspace_directory":"/workspace","skills":[]}`, + "plugins": `{"type":"self_hosted","workspace_directory":"/workspace","plugins":[{"name":"missing-identity"}]}`, + "skills": `{"type":"self_hosted","workspace_directory":"/workspace","skills":[{"name":"missing-identity"}]}`, "wrong type": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":false}`, "invalid": `{"type":"self_hosted","workspace_directory":`, } { diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 47560cf0b..5b2c5df5d 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -164,7 +164,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go index 74c257403..40fce6fc5 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -5,6 +5,7 @@ import ( "encoding/json" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -17,6 +18,10 @@ var errNetworkPolicy = &fieldError{message: "network access must be enabled, dis // accepting the protocol's omitted/null/empty defaults for the basic profile. // Unsupported fields are reported before network policy, independently of map order. func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { + return decodePreparedEnvironment(raw) +} + +func decodePreparedEnvironment(raw json.RawMessage) (*v1.Environment, error) { var fields map[string]json.RawMessage if json.Unmarshal(raw, &fields) != nil { return nil, store.ErrInvalidInput @@ -25,10 +30,18 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if err != nil { return nil, err } - env := &v1.Environment{Type: "openai_hosted", Network: &v1.EnvironmentNetworkInput{Access: "enabled"}} + var kind string + if json.Unmarshal(fields["type"], &kind) != nil || (kind != "openai_hosted" && kind != "self_hosted") { + return nil, store.ErrInvalidInput + } + env := &v1.Environment{Type: kind, Network: &v1.EnvironmentNetworkInput{Access: "enabled"}} for name, value := range fields { switch name { case "type": + case "workspace_directory": + if kind != "self_hosted" || json.Unmarshal(value, &env.WorkspaceDirectory) != nil { + return nil, store.ErrInvalidInput + } case "network": case "files": files, err := decodeInitialFiles(value) @@ -61,6 +74,12 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { } env.Network = &network } + if kind == "openai_hosted" && agentcapabilities.ValidateDirectories(env.CapabilityDirectories) != nil { + return nil, store.ErrInvalidInput + } + if kind == "self_hosted" && agentcapabilities.ValidateSourceDirectories([]string{env.WorkspaceDirectory}) != nil { + return nil, store.ErrInvalidInput + } return env, nil } @@ -95,7 +114,7 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if json.Unmarshal(fields["type"], &kind) != nil { return nil, store.ErrInvalidInput } - if kind != "openai_hosted" { + if kind != "openai_hosted" && kind != "self_hosted" { return decodeSessionEnvironment(raw) } // Confidential fields must never appear in the persisted public snapshot. @@ -141,7 +160,7 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if err != nil { return nil, err } - cfg, err := decodeHostedEnvironment(base) + cfg, err := decodePreparedEnvironment(base) if err != nil { return nil, err } diff --git a/services/agents-api/internal/api/hosted_failure_test.go b/services/agents-api/internal/api/hosted_failure_test.go index 16b102395..7a0f02674 100644 --- a/services/agents-api/internal/api/hosted_failure_test.go +++ b/services/agents-api/internal/api/hosted_failure_test.go @@ -51,8 +51,8 @@ func TestHostedProvisioningFailureSessionProjection(t *testing.T) { } session = environmentSession() session.EnvironmentFailure = &store.EnvironmentFailure{Reason: hostedFailureReason, FailedAt: failedAt} - if _, err := sessionResponse(session, environmentOrigin); err == nil { - t.Fatal("self-hosted Session accepted a hosted provisioning failure") + if value, err := sessionResponse(session, environmentOrigin); err != nil || value.Status != "failed" || value.Error == nil || *value.Error != hostedFailureReason { + t.Fatal("self-hosted preparation failure lost its common projection", value, err) } } diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index 0eff1a889..cd7b9f8bf 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -24,7 +24,7 @@ func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.R agentID = *input.AgentID } var environment any = input.Environment - if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() { + if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() || (input.XAgentsCore != nil && len(input.XAgentsCore.Environment) > 0) { environment = input.originalEnvironment if len(input.originalEnvironment) == 0 { environment = input.templateEnvironment diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go index bb08841e4..9dd2e510f 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -83,7 +83,7 @@ func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequ var revision uuid.UUID provider, source := inherited, v1.ModelProviderSourceAgent if extension := input.XAgentsCore; extension != nil { - if extension.ModelProvider == nil && !input.modelProviderNull && len(extension.HarnessConfig) == 0 { + if extension.ModelProvider == nil && !input.modelProviderNull && len(extension.HarnessConfig) == 0 && len(extension.Environment) == 0 { return "", nil, "", uuid.Nil, errors.New("x_agents_core requires an execution option") } if extension.ModelProvider != nil { diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index 8e26e360e..6e07fc59b 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -41,7 +41,7 @@ type sessionRequest struct { func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} if len(request.Execution) > 0 && !bytes.Equal(bytes.TrimSpace(request.Execution), []byte("null")) { - if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "harness_config") != nil { + if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "harness_config", "environment") != nil { return input, store.ErrInvalidInput } var fields map[string]json.RawMessage @@ -59,27 +59,24 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { } input.VaultIDs = append(input.VaultIDs, *id) } + canonicalEnvironment, err := preparationEnvironmentInput(request.Environment, input.XAgentsCore) + if err != nil { + return input, err + } input.originalEnvironment = request.Environment var environmentFields map[string]json.RawMessage - if json.Unmarshal(request.Environment, &environmentFields) != nil { + if json.Unmarshal(canonicalEnvironment, &environmentFields) != nil { return input, store.ErrInvalidInput } - var err error - var environmentType string - _ = json.Unmarshal(environmentFields["type"], &environmentType) - // Self-hosted paths are frozen in Environment.Configuration. They do not - // create managed initialization state or require a managed allocation. - if environmentType != "self_hosted" { - input.initialization, err = decodeEnvironmentSetup(environmentFields) - if err != nil { - return input, err - } + input.initialization, err = decodeEnvironmentSetup(environmentFields) + if err != nil { + return input, err } input.initialFiles, err = decodeInitialFiles(environmentFields["files"]) if err != nil { return input, err } - input.Environment, input.templateID, input.templateEnvironment, err = decodeTemplateEnvironment(request.Environment) + input.Environment, input.templateID, input.templateEnvironment, err = decodePreparationTemplate(canonicalEnvironment, true) if err != nil { return input, err } diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go index c72ae49c7..66c001c64 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/agents-api/internal/api/session_response.go @@ -101,7 +101,7 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err // A hosted provisioning failure is terminal and supersedes the settled input // activity: the Session reports its safe reason and failure time. if failure := session.EnvironmentFailure; failure != nil { - if cfg.Environment.Type != "openai_hosted" { + if cfg.Environment.Type != "openai_hosted" && cfg.Environment.Type != "self_hosted" { return v1.Session{}, errors.New("unsupported stored environment failure") } reason := failure.Reason diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 2cc05d800..fa15211b8 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -15,6 +15,10 @@ import ( // Validate the reference and inline shape without looking up mutable resources. // Caller intent remains available before any reference is resolved. func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, json.RawMessage, error) { + return decodePreparationTemplate(raw, false) +} + +func decodePreparationTemplate(raw json.RawMessage, extension bool) (*v1.Environment, string, json.RawMessage, error) { var fields map[string]json.RawMessage if json.Unmarshal(raw, &fields) != nil { return nil, "", nil, store.ErrInvalidInput @@ -22,10 +26,15 @@ func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, js reference, supplied := fields["environment_template_id"] if !supplied { environment, err := decodeSessionEnvironment(raw) + var kind string + _ = json.Unmarshal(fields["type"], &kind) + if extension && kind != "none" { + environment, err = decodePreparedEnvironment(raw) + } return environment, "", nil, err } var id, kind string - if json.Unmarshal(reference, &id) != nil || id == "" || json.Unmarshal(fields["type"], &kind) != nil || kind != "openai_hosted" { + if json.Unmarshal(reference, &id) != nil || id == "" || json.Unmarshal(fields["type"], &kind) != nil || (kind != "openai_hosted" && !(extension && kind == "self_hosted")) { return nil, "", nil, store.ErrInvalidInput } delete(fields, "environment_template_id") @@ -33,7 +42,7 @@ func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, js if err != nil { return nil, "", nil, err } - environment, err := decodeHostedEnvironment(inline) + environment, err := decodePreparedEnvironment(inline) return environment, id, raw, err } @@ -49,6 +58,9 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if json.Unmarshal(input.templateEnvironment, &fields) != nil { return store.ErrInvalidInput } + if input.Environment.Type == "self_hosted" && template.NetworkAccess != "enabled" { + return &fieldError{param: "x_agents_core.environment.environment_template_id", message: "This template requires a managed network policy; user-managed machines do not enforce it."} + } if !templateFieldOverride(fields, "network") { input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess, AllowedDomains: append([]string{}, template.AllowedDomains...)} } @@ -113,7 +125,7 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, input.Environment.Packages = &packages input.initialFiles = files input.Environment.Files = initialFileResponse(files) - // Runtime sees only the effective ordinary hosted configuration. + // Runtime sees only the effective preparation snapshot. return nil } diff --git a/services/agents-api/internal/db/queries/environment_initialization.sql b/services/agents-api/internal/db/queries/environment_initialization.sql new file mode 100644 index 000000000..d1452e781 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_initialization.sql @@ -0,0 +1,18 @@ +-- name: ListEnvironmentInitializations :many +SELECT e.id, e.session_id, s.tenant_id, s.engine, e.initialization, b.device_id +FROM environments e JOIN sessions s ON s.id = e.session_id +LEFT JOIN session_devices b ON b.session_id = s.id +WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') + AND e.initialization IN ('pending', 'running') +ORDER BY e.id LIMIT 32; + +-- name: ClaimEnvironmentInitialization :execrows +UPDATE environments SET initialization = 'running' +WHERE id = $1 AND initialization = 'pending' AND status NOT IN ('failed', 'expired'); + +-- name: CompleteEnvironmentInitialization :execrows +UPDATE environments SET initialization = 'complete' +WHERE id = $1 AND initialization = 'running' AND status NOT IN ('failed', 'expired'); + +-- name: FailEnvironmentInitialization :exec +UPDATE environments SET initialization = 'failed' WHERE id = $1 AND initialization <> 'complete'; diff --git a/services/agents-api/internal/db/queries/environments.sql b/services/agents-api/internal/db/queries/environments.sql index dfa4177f1..38fd0896f 100644 --- a/services/agents-api/internal/db/queries/environments.sql +++ b/services/agents-api/internal/db/queries/environments.sql @@ -1,5 +1,7 @@ -- name: CreateEnvironment :exec -INSERT INTO environments (id, session_id) VALUES ($1, $2); +INSERT INTO environments (id, session_id, initialization) +VALUES ($1, $2, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files WHERE session_id = $2) + OR EXISTS (SELECT 1 FROM environment_setups WHERE session_id = $2) THEN 'pending' ELSE 'complete' END); -- name: GetEnvironment :one SELECT sqlc.embed(e), s.tenant_id, (s.configuration->'environment')::jsonb AS configuration diff --git a/services/agents-api/internal/db/queries/initial_environment_files.sql b/services/agents-api/internal/db/queries/initial_environment_files.sql index 5f16254d0..df3bae7ca 100644 --- a/services/agents-api/internal/db/queries/initial_environment_files.sql +++ b/services/agents-api/internal/db/queries/initial_environment_files.sql @@ -11,26 +11,8 @@ SELECT f.* FROM initial_environment_files f JOIN sessions s ON s.id = f.session_ WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_at IS NULL; -- name: GetSessionInitializationReady :one -SELECT NOT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = s.id AND a.initialization <> 'complete' -) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) - AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) - OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready +SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.session_id = s.id AND e.initialization <> 'complete') AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2; --- name: ClaimRuntimeInitialization :one -UPDATE runtime_allocations SET initialization = 'running' -WHERE id = $1 AND initialization = 'pending' AND state = 'running' AND create_settled -AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING *; - --- name: CompleteRuntimeInitialization :one -UPDATE runtime_allocations SET initialization = 'complete' -WHERE id = $1 AND initialization = 'running' AND state = 'running' AND create_settled -AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING *; - -- name: LockInitialSourceFile :one SELECT * FROM source_files WHERE tenant_id = $1 AND id = $2 FOR SHARE; diff --git a/services/agents-api/internal/db/queries/runtime_allocations.sql b/services/agents-api/internal/db/queries/runtime_allocations.sql index 529adb727..0296b6249 100644 --- a/services/agents-api/internal/db/queries/runtime_allocations.sql +++ b/services/agents-api/internal/db/queries/runtime_allocations.sql @@ -1,6 +1,6 @@ -- name: CreateRuntimeAllocation :one -INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, node_id, deployment_generation, initialization) -VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING *; +INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, node_id, deployment_generation) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetRuntimeAllocation :one SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired diff --git a/services/agents-api/internal/db/queries/runtime_nodes.sql b/services/agents-api/internal/db/queries/runtime_nodes.sql index f21e40ed4..604a338e0 100644 --- a/services/agents-api/internal/db/queries/runtime_nodes.sql +++ b/services/agents-api/internal/db/queries/runtime_nodes.sql @@ -77,7 +77,7 @@ WHERE environment_id IN(SELECT id FROM environments WHERE session_id=$1) AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=runtime_placements.environment_id); -- name: ListNodeRuntimeAllocations :many -SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,a.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id +SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id WHERE a.node_id=$1 AND a.state<>'released' ORDER BY a.created_at,a.id LIMIT 1000; diff --git a/services/agents-api/internal/db/queries/runtime_suspension.sql b/services/agents-api/internal/db/queries/runtime_suspension.sql index 5c137172d..d4b6a6e21 100644 --- a/services/agents-api/internal/db/queries/runtime_suspension.sql +++ b/services/agents-api/internal/db/queries/runtime_suspension.sql @@ -5,8 +5,8 @@ SET compute_phase_changed_at = CASE WHEN compute_phase = sqlc.arg(phase)::text T compute_revision = compute_revision + 1, compute_retained_until = sqlc.narg(retained_until), kept_at = CASE WHEN sqlc.arg(phase)::text = 'running' THEN clock_timestamp() ELSE kept_at END -WHERE id = sqlc.arg(id) AND compute_revision = sqlc.arg(revision) - AND state = 'running' AND initialization = 'complete' +WHERE runtime_allocations.id = sqlc.arg(id) AND compute_revision = sqlc.arg(revision) + AND state = 'running' AND EXISTS (SELECT 1 FROM environments e WHERE e.id = runtime_allocations.environment_id AND e.initialization = 'complete') AND ((compute_phase IN ('disabled','running') AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour')) OR (compute_phase NOT IN ('disabled','running') AND compute_retained_until > clock_timestamp())) RETURNING *; diff --git a/services/agents-api/internal/db/sqlc/environment_initialization.sql.go b/services/agents-api/internal/db/sqlc/environment_initialization.sql.go new file mode 100644 index 000000000..102d4837d --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_initialization.sql.go @@ -0,0 +1,92 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_initialization.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const claimEnvironmentInitialization = `-- name: ClaimEnvironmentInitialization :execrows +UPDATE environments SET initialization = 'running' +WHERE id = $1 AND initialization = 'pending' AND status NOT IN ('failed', 'expired') +` + +func (q *Queries) ClaimEnvironmentInitialization(ctx context.Context, id pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, claimEnvironmentInitialization, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const completeEnvironmentInitialization = `-- name: CompleteEnvironmentInitialization :execrows +UPDATE environments SET initialization = 'complete' +WHERE id = $1 AND initialization = 'running' AND status NOT IN ('failed', 'expired') +` + +func (q *Queries) CompleteEnvironmentInitialization(ctx context.Context, id pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, completeEnvironmentInitialization, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const failEnvironmentInitialization = `-- name: FailEnvironmentInitialization :exec +UPDATE environments SET initialization = 'failed' WHERE id = $1 AND initialization <> 'complete' +` + +func (q *Queries) FailEnvironmentInitialization(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, failEnvironmentInitialization, id) + return err +} + +const listEnvironmentInitializations = `-- name: ListEnvironmentInitializations :many +SELECT e.id, e.session_id, s.tenant_id, s.engine, e.initialization, b.device_id +FROM environments e JOIN sessions s ON s.id = e.session_id +LEFT JOIN session_devices b ON b.session_id = s.id +WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') + AND e.initialization IN ('pending', 'running') +ORDER BY e.id LIMIT 32 +` + +type ListEnvironmentInitializationsRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + Engine string `json:"engine"` + Initialization string `json:"initialization"` + DeviceID pgtype.UUID `json:"device_id"` +} + +func (q *Queries) ListEnvironmentInitializations(ctx context.Context, id pgtype.UUID) ([]ListEnvironmentInitializationsRow, error) { + rows, err := q.db.Query(ctx, listEnvironmentInitializations, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnvironmentInitializationsRow{} + for rows.Next() { + var i ListEnvironmentInitializationsRow + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.TenantID, + &i.Engine, + &i.Initialization, + &i.DeviceID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/db/sqlc/environments.sql.go b/services/agents-api/internal/db/sqlc/environments.sql.go index 03b2d24a3..f1c3c45e9 100644 --- a/services/agents-api/internal/db/sqlc/environments.sql.go +++ b/services/agents-api/internal/db/sqlc/environments.sql.go @@ -12,7 +12,9 @@ import ( ) const createEnvironment = `-- name: CreateEnvironment :exec -INSERT INTO environments (id, session_id) VALUES ($1, $2) +INSERT INTO environments (id, session_id, initialization) +VALUES ($1, $2, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files WHERE session_id = $2) + OR EXISTS (SELECT 1 FROM environment_setups WHERE session_id = $2) THEN 'pending' ELSE 'complete' END) ` type CreateEnvironmentParams struct { @@ -26,7 +28,7 @@ func (q *Queries) CreateEnvironment(ctx context.Context, arg CreateEnvironmentPa } const getEnvironment = `-- name: GetEnvironment :one -SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration FROM environments e JOIN sessions s ON s.id = e.session_id WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL ` @@ -53,6 +55,7 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams) &i.Environment.FailureReason, &i.Environment.FailedAt, &i.Environment.FailureDetail, + &i.Environment.Initialization, &i.TenantID, &i.Configuration, ) @@ -60,7 +63,7 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams) } const getSessionEnvironment = `-- name: GetSessionEnvironment :one -SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration +SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration FROM environments e JOIN sessions s ON s.id = e.session_id WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL ` @@ -87,6 +90,7 @@ func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvir &i.Environment.FailureReason, &i.Environment.FailedAt, &i.Environment.FailureDetail, + &i.Environment.Initialization, &i.TenantID, &i.Configuration, ) diff --git a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go index f90ae5bf5..051caa0c4 100644 --- a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go +++ b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go @@ -11,76 +11,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const claimRuntimeInitialization = `-- name: ClaimRuntimeInitialization :one -UPDATE runtime_allocations SET initialization = 'running' -WHERE id = $1 AND initialization = 'pending' AND state = 'running' AND create_settled -AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation -` - -func (q *Queries) ClaimRuntimeInitialization(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { - row := q.db.QueryRow(ctx, claimRuntimeInitialization, id) - var i RuntimeAllocation - err := row.Scan( - &i.ID, - &i.EnvironmentID, - &i.DeviceID, - &i.ProviderKey, - &i.State, - &i.CreateSettled, - &i.CreatedAt, - &i.KeptAt, - &i.ReleasedAt, - &i.Initialization, - &i.ComputePhase, - &i.ComputeRevision, - &i.ComputeState, - &i.ComputeActivityAt, - &i.ComputeWakeRequested, - &i.ComputeRetainedUntil, - &i.NodeID, - &i.ObservationError, - &i.ComputePhaseChangedAt, - &i.DeploymentGeneration, - ) - return i, err -} - -const completeRuntimeInitialization = `-- name: CompleteRuntimeInitialization :one -UPDATE runtime_allocations SET initialization = 'complete' -WHERE id = $1 AND initialization = 'running' AND state = 'running' AND create_settled -AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation -` - -func (q *Queries) CompleteRuntimeInitialization(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { - row := q.db.QueryRow(ctx, completeRuntimeInitialization, id) - var i RuntimeAllocation - err := row.Scan( - &i.ID, - &i.EnvironmentID, - &i.DeviceID, - &i.ProviderKey, - &i.State, - &i.CreateSettled, - &i.CreatedAt, - &i.KeptAt, - &i.ReleasedAt, - &i.Initialization, - &i.ComputePhase, - &i.ComputeRevision, - &i.ComputeState, - &i.ComputeActivityAt, - &i.ComputeWakeRequested, - &i.ComputeRetainedUntil, - &i.NodeID, - &i.ObservationError, - &i.ComputePhaseChangedAt, - &i.DeploymentGeneration, - ) - return i, err -} - const createInitialEnvironmentFile = `-- name: CreateInitialEnvironmentFile :exec INSERT INTO initial_environment_files (id, session_id, position, path, size_bytes, contents) VALUES ($1, $2, $3, $4, $5, $6) @@ -133,13 +63,7 @@ func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialE } const getSessionInitializationReady = `-- name: GetSessionInitializationReady :one -SELECT NOT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = s.id AND a.initialization <> 'complete' -) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) - AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) - OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready +SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.session_id = s.id AND e.initialization <> 'complete') AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2 ` @@ -148,9 +72,9 @@ type GetSessionInitializationReadyParams struct { ID pgtype.UUID `json:"id"` } -func (q *Queries) GetSessionInitializationReady(ctx context.Context, arg GetSessionInitializationReadyParams) (pgtype.Bool, error) { +func (q *Queries) GetSessionInitializationReady(ctx context.Context, arg GetSessionInitializationReadyParams) (bool, error) { row := q.db.QueryRow(ctx, getSessionInitializationReady, arg.TenantID, arg.ID) - var ready pgtype.Bool + var ready bool err := row.Scan(&ready) return ready, err } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 67901e507..736d05c02 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -84,13 +84,14 @@ type Device struct { } type Environment struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - FailureReason pgtype.Text `json:"failure_reason"` - FailedAt pgtype.Timestamptz `json:"failed_at"` - FailureDetail []byte `json:"failure_detail"` + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + FailureReason pgtype.Text `json:"failure_reason"` + FailedAt pgtype.Timestamptz `json:"failed_at"` + FailureDetail []byte `json:"failure_detail"` + Initialization string `json:"initialization"` } type EnvironmentConnection struct { @@ -239,7 +240,6 @@ type RuntimeAllocation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` KeptAt pgtype.Timestamptz `json:"kept_at"` ReleasedAt pgtype.Timestamptz `json:"released_at"` - Initialization string `json:"initialization"` ComputePhase string `json:"compute_phase"` ComputeRevision int64 `json:"compute_revision"` ComputeState []byte `json:"compute_state"` diff --git a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go index 6aa63cef7..7e47aef04 100644 --- a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go @@ -12,8 +12,8 @@ import ( ) const createRuntimeAllocation = `-- name: CreateRuntimeAllocation :one -INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, node_id, deployment_generation, initialization) -VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, node_id, deployment_generation) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` type CreateRuntimeAllocationParams struct { @@ -45,7 +45,6 @@ func (q *Queries) CreateRuntimeAllocation(ctx context.Context, arg CreateRuntime &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, @@ -61,7 +60,7 @@ func (q *Queries) CreateRuntimeAllocation(ctx context.Context, arg CreateRuntime } const getRuntimeAllocation = `-- name: GetRuntimeAllocation :one -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.initialization, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id JOIN sessions s ON s.id = e.session_id @@ -94,7 +93,6 @@ func (q *Queries) GetRuntimeAllocation(ctx context.Context, arg GetRuntimeAlloca &i.RuntimeAllocation.CreatedAt, &i.RuntimeAllocation.KeptAt, &i.RuntimeAllocation.ReleasedAt, - &i.RuntimeAllocation.Initialization, &i.RuntimeAllocation.ComputePhase, &i.RuntimeAllocation.ComputeRevision, &i.RuntimeAllocation.ComputeState, @@ -117,7 +115,7 @@ const keepRuntimeAllocation = `-- name: KeepRuntimeAllocation :one UPDATE runtime_allocations SET kept_at = clock_timestamp() WHERE id = $1 AND state = 'running' AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` func (q *Queries) KeepRuntimeAllocation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { @@ -133,7 +131,6 @@ func (q *Queries) KeepRuntimeAllocation(ctx context.Context, id pgtype.UUID) (Ru &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, @@ -149,7 +146,7 @@ func (q *Queries) KeepRuntimeAllocation(ctx context.Context, id pgtype.UUID) (Ru } const listRuntimeAllocations = `-- name: ListRuntimeAllocations :many -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.initialization, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id JOIN sessions s ON s.id = e.session_id @@ -184,7 +181,6 @@ func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([ &i.RuntimeAllocation.CreatedAt, &i.RuntimeAllocation.KeptAt, &i.RuntimeAllocation.ReleasedAt, - &i.RuntimeAllocation.Initialization, &i.RuntimeAllocation.ComputePhase, &i.RuntimeAllocation.ComputeRevision, &i.RuntimeAllocation.ComputeState, @@ -291,7 +287,7 @@ const observeRuntimeRunning = `-- name: ObserveRuntimeRunning :one UPDATE runtime_allocations SET state = 'running', create_settled = true WHERE id = $1 AND state IN ('creating', 'running') AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour') -RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` func (q *Queries) ObserveRuntimeRunning(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { @@ -307,7 +303,6 @@ func (q *Queries) ObserveRuntimeRunning(ctx context.Context, id pgtype.UUID) (Ru &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, @@ -324,7 +319,7 @@ func (q *Queries) ObserveRuntimeRunning(ctx context.Context, id pgtype.UUID) (Ru const releaseRuntimeAllocation = `-- name: ReleaseRuntimeAllocation :one UPDATE runtime_allocations SET state = 'released', released_at = clock_timestamp() -WHERE id = $1 AND state = 'cleanup_pending' AND create_settled RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +WHERE id = $1 AND state = 'cleanup_pending' AND create_settled RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` func (q *Queries) ReleaseRuntimeAllocation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { @@ -340,7 +335,6 @@ func (q *Queries) ReleaseRuntimeAllocation(ctx context.Context, id pgtype.UUID) &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, @@ -357,7 +351,7 @@ func (q *Queries) ReleaseRuntimeAllocation(ctx context.Context, id pgtype.UUID) const requestRuntimeCleanup = `-- name: RequestRuntimeCleanup :one UPDATE runtime_allocations SET state = 'cleanup_pending' -WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` func (q *Queries) RequestRuntimeCleanup(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { @@ -373,7 +367,6 @@ func (q *Queries) RequestRuntimeCleanup(ctx context.Context, id pgtype.UUID) (Ru &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, @@ -390,7 +383,7 @@ func (q *Queries) RequestRuntimeCleanup(ctx context.Context, id pgtype.UUID) (Ru const settleRuntimeCreation = `-- name: SettleRuntimeCreation :one UPDATE runtime_allocations SET create_settled = true -WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +WHERE id = $1 AND state <> 'released' RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` func (q *Queries) SettleRuntimeCreation(ctx context.Context, id pgtype.UUID) (RuntimeAllocation, error) { @@ -406,7 +399,6 @@ func (q *Queries) SettleRuntimeCreation(ctx context.Context, id pgtype.UUID) (Ru &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, diff --git a/services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go b/services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go index be0d53eae..6036aa233 100644 --- a/services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_lifecycle_nodes.sql.go @@ -50,7 +50,7 @@ func (q *Queries) GetRuntimeLifecyclePlacement(ctx context.Context, arg GetRunti } const listRuntimeAllocationsForNode = `-- name: ListRuntimeAllocationsForNode :many -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.initialization, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.kept_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (CASE WHEN a.compute_phase NOT IN ('disabled', 'running') THEN a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp() ELSE a.node_id IS NULL AND (SELECT mode FROM runtime_deployment) <> 'direct' AND a.kept_at <= clock_timestamp() - interval '1 hour' END)::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id @@ -91,7 +91,6 @@ func (q *Queries) ListRuntimeAllocationsForNode(ctx context.Context, arg ListRun &i.RuntimeAllocation.CreatedAt, &i.RuntimeAllocation.KeptAt, &i.RuntimeAllocation.ReleasedAt, - &i.RuntimeAllocation.Initialization, &i.RuntimeAllocation.ComputePhase, &i.RuntimeAllocation.ComputeRevision, &i.RuntimeAllocation.ComputeState, diff --git a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go index cb73f908c..c2507e7f5 100644 --- a/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_nodes.sql.go @@ -334,7 +334,7 @@ func (q *Queries) InsertRuntimeNode(ctx context.Context, arg InsertRuntimeNodePa } const listNodeRuntimeAllocations = `-- name: ListNodeRuntimeAllocations :many -SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,a.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id +SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id WHERE a.node_id=$1 AND a.state<>'released' ORDER BY a.created_at,a.id LIMIT 1000 ` diff --git a/services/agents-api/internal/db/sqlc/runtime_suspension.sql.go b/services/agents-api/internal/db/sqlc/runtime_suspension.sql.go index 20f273ecc..5c4867839 100644 --- a/services/agents-api/internal/db/sqlc/runtime_suspension.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_suspension.sql.go @@ -135,11 +135,11 @@ SET compute_phase_changed_at = CASE WHEN compute_phase = $1::text THEN compute_p compute_revision = compute_revision + 1, compute_retained_until = $3, kept_at = CASE WHEN $1::text = 'running' THEN clock_timestamp() ELSE kept_at END -WHERE id = $4 AND compute_revision = $5 - AND state = 'running' AND initialization = 'complete' +WHERE runtime_allocations.id = $4 AND compute_revision = $5 + AND state = 'running' AND EXISTS (SELECT 1 FROM environments e WHERE e.id = runtime_allocations.environment_id AND e.initialization = 'complete') AND ((compute_phase IN ('disabled','running') AND (node_id IS NOT NULL OR (SELECT mode FROM runtime_deployment) = 'direct' OR kept_at > clock_timestamp() - interval '1 hour')) OR (compute_phase NOT IN ('disabled','running') AND compute_retained_until > clock_timestamp())) -RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation +RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, compute_phase, compute_revision, compute_state, compute_activity_at, compute_wake_requested, compute_retained_until, node_id, observation_error, compute_phase_changed_at, deployment_generation ` type SetRuntimeComputeParams struct { @@ -169,7 +169,6 @@ func (q *Queries) SetRuntimeCompute(ctx context.Context, arg SetRuntimeComputePa &i.CreatedAt, &i.KeptAt, &i.ReleasedAt, - &i.Initialization, &i.ComputePhase, &i.ComputeRevision, &i.ComputeState, diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/agents-api/internal/execution/environment_capabilities_test.go index 722494c8e..c9e48bf29 100644 --- a/services/agents-api/internal/execution/environment_capabilities_test.go +++ b/services/agents-api/internal/execution/environment_capabilities_test.go @@ -25,8 +25,6 @@ func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { `{"type":"self_hosted","workspace_directory":"/a/../b"}`, `{"type":"self_hosted","workspace_directory":"relative"}`, `{"type":"self_hosted","workspace_directory":"/work","capability_directories":["/a","/a"]}`, - `{"type":"self_hosted","workspace_directory":"/work","skills":[]}`, - `{"type":"self_hosted","workspace_directory":"/work","initialization":true}`, } { if LocalWorkspaceConfiguration([]byte(configuration)) { t.Fatal("invalid self-hosted shape accepted", configuration) diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index f29f5ee6e..97293f52d 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -35,50 +35,44 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem if json.Unmarshal(configuration, &placement) != nil { return placement, store.ErrInvalidInput } + var local struct { + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` + Files []store.InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` + Type string `json:"type"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + CapabilityDirectories []string `json:"capability_directories"` + Network *v1.EnvironmentNetworkInput `json:"network"` + } + decoder := json.NewDecoder(bytes.NewReader(configuration)) + decoder.DisallowUnknownFields() + if decoder.Decode(&local) != nil || agentcapabilities.ValidateSourceDirectories(local.CapabilityDirectories) != nil { + return placement, store.ErrInvalidInput + } + // Placement selects a workspace; all preparation fields are shared. switch placement.Type { - case "self_hosted": - var local struct { - Type string `json:"type"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilityDirectories []string `json:"capability_directories"` - } - decoder := json.NewDecoder(bytes.NewReader(configuration)) - decoder.DisallowUnknownFields() - if decoder.Decode(&local) == nil && validSelfHostedPlacement(placement) { - placement.NetworkAccess = "enabled" - return placement, nil - } case "openai_hosted": + if local.WorkspaceDirectory != "" || agentcapabilities.ValidateDirectories(local.CapabilityDirectories) != nil { + return placement, store.ErrInvalidInput + } placement.WorkspaceDirectory = "/workspace" - // Stored policy is shared by preparation and provider bootstrap. - var local struct { - Plugins []agentplugin.Metadata `json:"plugins,omitempty"` - Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` - Files []store.InitialFileMetadata `json:"files"` - Packages *v1.EnvironmentPackages `json:"packages,omitempty"` - Initialization bool `json:"initialization,omitempty"` - Type string `json:"type"` - CapabilityDirectories []string `json:"capability_directories"` - Network *struct { - Access string `json:"access"` - AllowedDomains []string `json:"allowed_domains"` - } `json:"network"` + case "self_hosted": + if !validSelfHostedPlacement(placement) { + return placement, store.ErrInvalidInput } - decoder := json.NewDecoder(bytes.NewReader(configuration)) - decoder.DisallowUnknownFields() - if decoder.Decode(&local) == nil && agentcapabilities.ValidateDirectories(local.CapabilityDirectories) == nil { - placement.NetworkAccess = "enabled" - if local.Network != nil { - if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { - return placement, store.ErrInvalidInput - } - placement.NetworkAccess = local.Network.Access - placement.AllowedDomains = append([]string(nil), local.Network.AllowedDomains...) - } - return placement, nil + default: + return placement, store.ErrInvalidInput + } + placement.NetworkAccess = "enabled" + if local.Network != nil { + if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { + return placement, store.ErrInvalidInput } + placement.NetworkAccess, placement.AllowedDomains = local.Network.Access, append([]string(nil), local.Network.AllowedDomains...) } - return placement, store.ErrInvalidInput + return placement, nil } func environmentDeviceMatches(session store.Session, environment store.Environment, bound store.ExecutionDevice) bool { @@ -113,6 +107,5 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ func validSelfHostedPlacement(placement environmentPlacement) bool { return agentcapabilities.ValidateSourceDirectories([]string{placement.WorkspaceDirectory}) == nil && - agentcapabilities.ValidateSourceDirectories(placement.CapabilityDirectories) == nil && - !placement.ToolEnvironment && len(placement.Skills)+len(placement.Plugins) == 0 + agentcapabilities.ValidateSourceDirectories(placement.CapabilityDirectories) == nil } diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/agents-api/internal/execution/runtime_capabilities_test.go index 4e6fb9030..c141327cc 100644 --- a/services/agents-api/internal/execution/runtime_capabilities_test.go +++ b/services/agents-api/internal/execution/runtime_capabilities_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -76,10 +75,3 @@ func TestRuntimeCapabilitiesConfirmedAndUnknownFailures(t *testing.T) { t.Fatal("transport error leaked or succeeded", err) } } -func TestRuntimeInitializationWaitsForMissingSocket(t *testing.T) { - lifecycle := runtimeLifecycle{registry: gateway.NewRegistry()} - peer, err := lifecycle.initializationPeer(t.Context(), store.RuntimeAllocation{DeviceID: uuid.NewString()}) - if err != nil || peer != nil || lifecycle.initializing != nil { - t.Fatal("missing socket became initialization failure", peer, err) - } -} diff --git a/services/agents-api/internal/execution/runtime_connections.go b/services/agents-api/internal/execution/runtime_connections.go index 7808a9128..359c9a54a 100644 --- a/services/agents-api/internal/execution/runtime_connections.go +++ b/services/agents-api/internal/execution/runtime_connections.go @@ -20,10 +20,10 @@ type runtimeConnection struct { } func (r *runtimeLifecycle) observeConnection(ctx context.Context, owner store.RuntimeAllocation) error { - if owner.Initialization != "complete" { + bound, err := r.store.GetSessionRuntimeDevice(ctx, owner.TenantID, owner.SessionID) + if errors.Is(err, store.ErrNotFound) { return nil } - bound, err := r.store.GetSessionDevice(ctx, owner.TenantID, owner.SessionID) if err != nil { return err } diff --git a/services/agents-api/internal/execution/runtime_connections_test.go b/services/agents-api/internal/execution/runtime_connections_test.go index 492447b55..40148de39 100644 --- a/services/agents-api/internal/execution/runtime_connections_test.go +++ b/services/agents-api/internal/execution/runtime_connections_test.go @@ -6,21 +6,19 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -func TestRuntimeCleanupDropsEnvironmentConnectionAndOnlyOwnedInitialization(t *testing.T) { +func TestRuntimeCleanupDropsOnlyOwnedEnvironmentConnection(t *testing.T) { first := store.RuntimeAllocation{ID: "allocation-one", EnvironmentID: "environment-one"} second := store.RuntimeAllocation{ID: "allocation-two", EnvironmentID: "environment-two"} retained := &runtimeConnection{} - initializing := &runtimeInitialization{owner: second} r := &runtimeLifecycle{ - connections: map[string]*runtimeConnection{first.EnvironmentID: {}, second.EnvironmentID: retained}, - initializing: initializing, + connections: map[string]*runtimeConnection{first.EnvironmentID: {}, second.EnvironmentID: retained}, } r.clearRuntimeState(first) - if len(r.connections) != 1 || r.connections[second.EnvironmentID] != retained || r.initializing != initializing { + if len(r.connections) != 1 || r.connections[second.EnvironmentID] != retained { t.Fatal("cleanup retained the retired connection or discarded another Runtime's state") } r.clearRuntimeState(second) - if len(r.connections) != 0 || r.initializing != nil { + if len(r.connections) != 0 { t.Fatal("cleanup retained owned connection or initialization") } } diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go index cea3198ef..414bda0f4 100644 --- a/services/agents-api/internal/execution/runtime_initialization.go +++ b/services/agents-api/internal/execution/runtime_initialization.go @@ -4,156 +4,161 @@ import ( "context" "encoding/json" "errors" + "sync" "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// Progress is process-local: a recovered running installation is never replayed. -type runtimeInitialization struct { - owner store.RuntimeAllocation - next, count int - files int - operations []runtimeSetupOperation - deadline time.Time +// The leased Worker owns preparation for every Environment. Compute managers +// never run initialization. Bounded concurrent owners prevent one slow machine +// from blocking other environments or resource reclamation. +func (w *Worker) runEnvironmentInitializations(ctx context.Context) error { + active := make(map[string]bool) + done := make(chan string, w.executionConcurrency()) + var running sync.WaitGroup + ctx, stop := context.WithCancel(ctx) + defer func() { stop(); running.Wait() }() + cursor := "" + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case id := <-done: + delete(active, id) + case <-ticker.C: + } + rows, err := w.dispatcher.Store.ListEnvironmentInitializations(ctx, cursor) + if err != nil { + return err + } + if len(rows) == 0 { + cursor = "" + } + for _, owner := range rows { + cursor = owner.EnvironmentID + if active[owner.EnvironmentID] { + continue + } + if owner.State == "running" { + // Lost process-local progress cannot prove which side effects ran. + if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{}); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + continue + } + if len(active) >= w.executionConcurrency() { + continue + } + peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, gateway.ErrSessionClosed) || errors.Is(err, gateway.ErrDeviceNotRegistered) { + continue + } + return err + } + harness, found, known := peer.AgentKindStatus(owner.Engine) + if !known { + continue + } + if !found || !harness.Available { + if err := w.dispatcher.Store.FailEnvironmentInitialization(ctx, owner, store.ProvisioningFailure{Step: store.ProvisioningHarness}); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + continue + } + if err := w.dispatcher.Store.ClaimEnvironmentInitialization(ctx, owner); err != nil { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, store.ErrTurnConflict) { + continue + } + return err + } + active[owner.EnvironmentID] = true + running.Add(1) + go func(owner store.EnvironmentInitialization) { + defer running.Done() + w.initializeEnvironment(ctx, owner) + done <- owner.EnvironmentID + }(owner) + } + } } -func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner store.RuntimeAllocation) error { - if owner.Initialization == "complete" { - return nil - } - if owner.Initialization == "running" { - if r.initializing != nil && r.initializing.owner.ID == owner.ID { - return nil - } - _, err := r.store.RequestRuntimeCleanup(ctx, owner) - return err +func (w *Worker) initializeEnvironment(ctx context.Context, owner store.EnvironmentInitialization) { + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + failure := store.ProvisioningFailure{} + err := w.prepareEnvironment(operation, owner, &failure) + if err == nil { + err = w.dispatcher.Store.CompleteEnvironmentInitialization(operation, owner) } - if r.initializing != nil { - return nil + if err != nil { + log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID) + // A later scan settles an unrecorded failure; it never retries the setup. + record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer stop() + _ = w.dispatcher.Store.FailEnvironmentInitialization(record, owner, failure) } - environment, err := r.store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) +} + +func (w *Worker) prepareEnvironment(ctx context.Context, owner store.EnvironmentInitialization, failure *store.ProvisioningFailure) error { + environment, err := w.dispatcher.Store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) if err != nil { return err } var cfg struct { - Initialization bool `json:"initialization"` - Files []store.InitialFileMetadata `json:"files"` + Files []store.InitialFileMetadata `json:"files"` } if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { - _, err = r.store.RequestRuntimeCleanup(ctx, owner) - return err + return store.ErrInvalidInput } - setup, err := r.store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) + setup, err := w.dispatcher.Store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) if err != nil { return err } - operations := setupOperations(setup) - if len(cfg.Files)+len(operations) == 0 || cfg.Initialization != !setup.Empty() { - _, err = r.store.RequestRuntimeCleanup(ctx, owner) - return err - } - // No initialization is claimed before the authenticated Runtime is available. - peer, err := r.initializationPeer(ctx, owner) - if err != nil || peer == nil { - return err - } - claimed, err := r.store.ClaimRuntimeInitialization(ctx, owner) + peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) if err != nil { return err } - r.initializing = &runtimeInitialization{owner: claimed, count: len(cfg.Files) + len(operations), files: len(cfg.Files), operations: operations, deadline: time.Now().Add(30 * time.Minute)} - return nil -} - -// One bounded operation follows a full maintenance scan; other allocations get serviced between operations. -func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { - active := r.initializing - if active == nil { - return nil - } - owner, err := r.store.GetRuntimeAllocation(ctx, active.owner.TenantID, active.owner.EnvironmentID) - if err != nil { - return err - } - if owner.State == "cleanup_pending" || owner.State == "released" || owner.SessionDeleted || owner.Expired { - r.initializing = nil - return nil - } - if owner.Initialization == "complete" { - r.initializing = nil - return nil - } - if owner.ID != active.owner.ID || owner.Initialization != "running" { - r.initializing = nil - return sandbox.ErrOwnership - } - operation, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - if time.Now().After(active.deadline) { - r.initializing = nil - return sandbox.ErrCommandUnconfirmed - } - if err := r.store.CheckExecutionOwnership(operation); err != nil { - r.initializing = nil - return err - } - peer, err := r.initializationPeer(operation, owner) - if err == nil && peer == nil { - return nil - } identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID} - step := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} - if err == nil && active.next < active.files { - var file store.InitialFileMetadata - var body []byte - file, body, err = r.store.ReadInitialEnvironmentFile(operation, owner.TenantID, owner.SessionID, active.next) + operations := setupOperations(setup) + for index := 0; index < len(cfg.Files)+len(operations); index++ { + step, stop := context.WithTimeout(ctx, 2*time.Minute) + err = w.dispatcher.Store.CheckExecutionOwnership(step) if err == nil { - err = installInitialFile(operation, peer, identity, file, body) + var currentPeer = peer + currentPeer, err = w.dispatcher.authorizedPeer(step, owner.DeviceID) + if err == nil && currentPeer != peer { + err = errors.New("Runtime connection changed during initialization") + } } - } else if err == nil { - setup := active.operations[active.next-active.files] - step = setup.provisioningFailure(0) - err = runRuntimeSetup(operation, peer, identity, setup) - } - if err != nil { - // Clearing the in-memory owner makes the next observation request cleanup, - // even when the failed operation consumed its entire deadline. - r.initializing = nil - var failed *runtimeStepFailure - if errors.As(err, &failed) { - // A confirmed failed step records its safe reason now. Unknown effects - // keep the generic reason recorded by that later cleanup. - step.ExitCode = failed.exitCode - record, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - if _, cleanupErr := r.store.FailRuntimeInitialization(record, owner, step); cleanupErr != nil { - return errors.Join(err, cleanupErr) + candidate := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} + if err == nil && index < len(cfg.Files) { + var metadata store.InitialFileMetadata + var body []byte + metadata, body, err = w.dispatcher.Store.ReadInitialEnvironmentFile(step, owner.TenantID, owner.SessionID, index) + if err == nil { + err = installInitialFile(step, peer, identity, metadata, body) } + } else if err == nil { + command := operations[index-len(cfg.Files)] + candidate = command.provisioningFailure(0) + err = runRuntimeSetup(step, peer, identity, command) + } + stop() + if err != nil { + var confirmed *runtimeStepFailure + if errors.As(err, &confirmed) { + candidate.ExitCode = confirmed.exitCode + *failure = candidate + } + return err } - return err - } - active.next++ - if active.next == active.count { - _, err = r.store.CompleteRuntimeInitialization(operation, owner) - r.initializing = nil - return err } return nil } - -// Missing authority/socket before sending consumes no initialization operation. -// Once PrepareRuntime is called, unknown effects use the existing cleanup. -func (r *runtimeLifecycle) initializationPeer(ctx context.Context, owner store.RuntimeAllocation) (*gateway.Session, error) { - if r.registry == nil { - return nil, nil - } - peer, err := authorizedRuntimePeer(ctx, r.store, r.registry, owner.DeviceID) - if errors.Is(err, store.ErrNotFound) || errors.Is(err, gateway.ErrSessionClosed) || errors.Is(err, gateway.ErrDeviceNotRegistered) { - return nil, nil - } - return peer, err -} diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go index 3ed19c542..d372a4df9 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -52,7 +52,6 @@ type runtimeLifecycle struct { cursor string pendingCursor string connections map[string]*runtimeConnection - initializing *runtimeInitialization wakeHints chan struct{} } @@ -276,12 +275,6 @@ func (r *runtimeLifecycle) reconcile(ctx context.Context) error { if len(rows) == 0 { wrapped := r.cursor != "" r.cursor = "" - if err := r.advanceInitialization(ctx); err != nil { - if ownership := r.store.CheckExecutionOwnership(ctx); ownership != nil { - return ownership - } - log.Ctx(ctx).Warn("managed Runtime file initialization incomplete") - } if wrapped { // Service the next page now instead of spending a ticker interval on EOF. // Refill only once so an empty store still returns without spinning. @@ -313,13 +306,6 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAlloc if owner.ProviderKey != r.config.InstallationID || owner.NodeID != r.nodeID { return sandbox.ErrOwnership } - if owner.Initialization == "running" && (r.initializing == nil || r.initializing.owner.ID != owner.ID) { - var err error - owner, err = r.store.RequestRuntimeCleanup(ctx, owner) - if err != nil { - return err - } - } if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { var err error owner, err = r.store.RequestRuntimeCleanup(ctx, owner) @@ -404,10 +390,11 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAlloc if err != nil { return err } - if err := r.observeInitialization(ctx, owner); err != nil { + environment, err := r.store.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) + if err != nil { return err } - if r.config.Suspension != nil && owner.Initialization == "complete" { + if r.config.Suspension != nil && environment.Initialization == "complete" { return r.enableCompute(ctx, owner) } if peer, err := r.registry.LookupDevice(owner.DeviceID); err != nil || peer.IsClosed() { @@ -424,12 +411,9 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner store.RuntimeAlloc return err } -// Allocation identity owns initialization; Environment identity owns connectivity. +// Environment identity owns connectivity; preparation has an independent owner. func (r *runtimeLifecycle) clearRuntimeState(owner store.RuntimeAllocation) { delete(r.connections, owner.EnvironmentID) - if r.initializing != nil && r.initializing.owner.ID == owner.ID { - r.initializing = nil - } } func runtimeReference(owner store.RuntimeAllocation) sandbox.Reference { diff --git a/services/agents-api/internal/execution/runtime_wake_hint.go b/services/agents-api/internal/execution/runtime_wake_hint.go index 1aa6af62a..82320309e 100644 --- a/services/agents-api/internal/execution/runtime_wake_hint.go +++ b/services/agents-api/internal/execution/runtime_wake_hint.go @@ -24,12 +24,12 @@ func (w *Worker) hintRuntimeWake(ctx context.Context, session store.Session) { lookup, cancel := context.WithTimeout(ctx, time.Second) defer cancel() environment, err := w.admission.GetSessionEnvironment(lookup, session.TenantID, session.ID) - if err != nil { + if err != nil || environment.Initialization != "complete" { return } owner, err := w.admission.GetRuntimeAllocation(lookup, session.TenantID, environment.ID) if err != nil || owner.ProviderKey != config.InstallationID || owner.State != "running" || - !owner.CreateSettled || owner.Initialization != "complete" || owner.SessionDeleted || owner.Expired { + !owner.CreateSettled || owner.SessionDeleted || owner.Expired { return } switch owner.ComputePhase { diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 37f68a25f..26f9e4126 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -41,6 +41,14 @@ func (p Policy) ValidateSessionConfiguration(engine string, configuration json.R return store.ErrInvalidInput } } + if snapshot.Environment != nil && snapshot.Environment.Type != "none" { + // Current Runtime adapters cannot execute a restricted network policy. + // Admission must reject it before allocating compute. Runtime itself is + // not an enforcement boundary; new support needs outer qualification. + if network := snapshot.Environment.Network; network != nil && network.Access != "enabled" { + return errors.New("This execution environment does not support disabled or restricted networking. Use enabled networking; Runtime does not provide network isolation.") + } + } return validateProfileConfiguration(profile, snapshot) } diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index 46278d140..7062a4c8e 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -160,6 +160,9 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { err error } completed := make(chan completion, w.executionConcurrency()) + preparationDone := make(chan error, 1) + running.Add(1) + go func() { defer running.Done(); preparationDone <- w.runEnvironmentInitializations(ctx) }() lifecycleDone := make(chan error, 1) if w.runtimes != nil { running.Add(1) @@ -192,6 +195,8 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { select { case <-ctx.Done(): return ctx.Err() + case err := <-preparationDone: + return err case err := <-lifecycleDone: return err case request := <-w.fileWrites: diff --git a/services/agents-api/internal/store/admin_session_archive_test.go b/services/agents-api/internal/store/admin_session_archive_test.go index 4e9908715..18c761fc3 100644 --- a/services/agents-api/internal/store/admin_session_archive_test.go +++ b/services/agents-api/internal/store/admin_session_archive_test.go @@ -227,7 +227,10 @@ func TestManagedSessionArchivePreservesFailuresAndRejectsSelfHosted(t *testing.T s, w, installation := managedArchiveFixture(t) tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, installation) - if _, err := w.FailRuntimeInitialization(t.Context(), owner, ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { + if _, err := s.pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID); err != nil { + t.Fatal(err) + } + if err := w.FailEnvironmentInitialization(t.Context(), EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}, ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { t.Fatal(err) } failed, err := s.GetSession(t.Context(), tenant, session.ID) diff --git a/services/agents-api/internal/store/agent_execution_defaults_http_test.go b/services/agents-api/internal/store/agent_execution_defaults_http_test.go index cd3ab71b2..be50da945 100644 --- a/services/agents-api/internal/store/agent_execution_defaults_http_test.go +++ b/services/agents-api/internal/store/agent_execution_defaults_http_test.go @@ -27,7 +27,7 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { } deployment := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-canary"} defaultsCalls := 0 - handler, err := api.NewHandler(st, auth, "codex", api.WithHarnesses([]string{"codex", "claude_sdk", "mcode"}), api.WithHostedEnvironments(), api.WithExecution(st), api.WithModelProviderDefaults(func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { + handler, err := api.NewHandler(st, auth, "codex", api.WithHarnesses([]string{"codex", "claude_sdk", "mcode"}), api.WithHostedEnvironments(), api.WithEnvironmentRemoteURL("wss://core.example/api/v1/agent-daemon/ws"), api.WithExecution(st), api.WithModelProviderDefaults(func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { defaultsCalls++ copy := *deployment return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: ©, Revision: uuid.New()}, nil @@ -149,4 +149,25 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { assertSnapshot(sid, "inline-model", "https://deployment.example/v1", "deployment-canary") newID := id(call("POST", "/v1/agents/sessions", inline, uuid.NewString(), 201)) assertSnapshot(newID, "inline-model", "https://changed.example/v1", "changed-key") + // Preparation alone must not require resubmitting existing model credentials. + savedID := id(call("POST", "/v1/agents", createAgent, "", 201)) + for _, placement := range []string{"openai_hosted", "self_hosted"} { + environment := `{"type":"` + placement + `"}` + if placement == "self_hosted" { + environment = `{"type":"self_hosted","workspace_directory":"/work"}` + } + prepared := `{"agent_id":"` + savedID + `","environment":` + environment + `,"x_agents_core":{"environment":{"env":{"PREPARATION_PROOF":"saved"}}}}` + preparedID := id(call("POST", "/v1/agents/sessions", prepared, uuid.NewString(), 201)) + assertSnapshot(preparedID, "model-original", "https://saved.example/v1", "saved-canary") + } + preparedInline := strings.TrimSuffix(inline, "}") + `,"x_agents_core":{"environment":{"env":{"PREPARATION_PROOF":"deployment"}}}}` + preparedID := id(call("POST", "/v1/agents/sessions", preparedInline, uuid.NewString(), 201)) + assertSnapshot(preparedID, "inline-model", "https://changed.example/v1", "changed-key") + callsBefore := defaultsCalls + selfHosted := strings.Replace(preparedInline, `"type":"openai_hosted"`, `"type":"self_hosted","workspace_directory":"/work"`, 1) + call("POST", "/v1/agents/sessions", selfHosted, uuid.NewString(), 400) + if defaultsCalls != callsBefore { + t.Fatal("preparation extension sent deployment credentials to a user machine") + } + } diff --git a/services/agents-api/internal/store/devices.go b/services/agents-api/internal/store/devices.go index ed4050d0b..b3ef507e9 100644 --- a/services/agents-api/internal/store/devices.go +++ b/services/agents-api/internal/store/devices.go @@ -118,6 +118,16 @@ func (s *Store) GetSessionDevice(ctx context.Context, tenantID, sessionID string if err := s.requireInitializedEnvironment(ctx, params.TenantID, params.ID); err != nil { return ExecutionDevice{}, err } + return s.GetSessionRuntimeDevice(ctx, tenantID, sessionID) +} + +// GetSessionRuntimeDevice reports an authorized connection binding. It does not +// admit native execution or file access before Environment preparation completes. +func (s *Store) GetSessionRuntimeDevice(ctx context.Context, tenantID, sessionID string) (ExecutionDevice, error) { + params, err := deviceLookup(tenantID, sessionID) + if err != nil { + return ExecutionDevice{}, err + } row, err := s.queries.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams(params)) if errors.Is(err, pgx.ErrNoRows) { return ExecutionDevice{}, ErrNotFound diff --git a/services/agents-api/internal/store/environment_initialization.go b/services/agents-api/internal/store/environment_initialization.go new file mode 100644 index 000000000..426a0edca --- /dev/null +++ b/services/agents-api/internal/store/environment_initialization.go @@ -0,0 +1,116 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// EnvironmentInitialization owns preparation independently of compute ownership. +// A running record without its process-local owner is unknown, never replayable. +type EnvironmentInitialization struct { + EnvironmentID, SessionID, TenantID, DeviceID, State, Engine string +} + +func (s *Store) ListEnvironmentInitializations(ctx context.Context, after string) ([]EnvironmentInitialization, error) { + if err := s.CheckExecutionOwnership(ctx); err != nil { + return nil, err + } + id := pgtype.UUID{Valid: true} + if after != "" { + var err error + id, err = parseID(after) + if err != nil { + return nil, err + } + } + rows, err := s.queries.ListEnvironmentInitializations(ctx, id) + if err != nil { + return nil, err + } + result := make([]EnvironmentInitialization, 0, len(rows)) + for _, row := range rows { + result = append(result, EnvironmentInitialization{EnvironmentID: runtimeUUID(row.ID), SessionID: runtimeUUID(row.SessionID), TenantID: runtimeUUID(row.TenantID), DeviceID: runtimeUUID(row.DeviceID), State: row.Initialization, Engine: row.Engine}) + } + return result, nil +} + +func (s *Store) mutateEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization, apply func(*sqlc.Queries, sqlc.GetSessionEnvironmentRow) error) error { + if s.executionLease == nil { + return errors.New("Environment initialization requires execution ownership") + } + tenant, err := parseID(owner.TenantID) + if err != nil { + return err + } + return s.withPublicSession(ctx, owner.TenantID, owner.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: session}) + if err != nil { + return err + } + if runtimeUUID(row.Environment.ID) != owner.EnvironmentID { + return ErrDeviceBindingConflict + } + if row.Environment.Status == "failed" || row.Environment.Status == "expired" { + return ErrNotFound + } + return apply(q, row) + }) +} + +func (s *Store) ClaimEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization) error { + return s.mutateEnvironmentInitialization(ctx, owner, func(q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow) error { + if err := checkInitializationDevice(ctx, q, owner, row); err != nil { + return err + } + count, err := q.ClaimEnvironmentInitialization(ctx, row.Environment.ID) + if err == nil && count != 1 { + return ErrTurnConflict + } + return err + }) +} + +func checkInitializationDevice(ctx context.Context, q *sqlc.Queries, owner EnvironmentInitialization, row sqlc.GetSessionEnvironmentRow) error { + tenant, _ := parseID(owner.TenantID) + bound, err := q.GetSessionDevice(ctx, sqlc.GetSessionDeviceParams{TenantID: tenant, ID: row.Environment.SessionID}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if runtimeUUID(bound.ID) != owner.DeviceID || bound.EnvironmentID != row.Environment.ID { + return ErrTurnConflict + } + return nil +} + +func (s *Store) CompleteEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization) error { + return s.mutateEnvironmentInitialization(ctx, owner, func(q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow) error { + if err := checkInitializationDevice(ctx, q, owner, row); err != nil { + return err + } + count, err := q.CompleteEnvironmentInitialization(ctx, row.Environment.ID) + if err == nil && count != 1 { + return ErrTurnConflict + } + return err + }) +} + +// Failure records state and settles work, never destroys compute or a workspace. +func (s *Store) FailEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization, failure ProvisioningFailure) error { + return s.mutateEnvironmentInitialization(ctx, owner, func(q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow) error { + if row.Environment.Initialization == "complete" { + return ErrTurnConflict + } + if err := q.FailEnvironmentInitialization(ctx, row.Environment.ID); err != nil { + return err + } + return failEnvironment(ctx, q, row, row.Environment.SessionID, failure.reason(), failure.detail(), func() error { return cancelSessionWork(ctx, q, row.Environment.SessionID) }) + }) +} diff --git a/services/agents-api/internal/store/environment_initialization_test.go b/services/agents-api/internal/store/environment_initialization_test.go new file mode 100644 index 000000000..32d4af3ac --- /dev/null +++ b/services/agents-api/internal/store/environment_initialization_test.go @@ -0,0 +1,226 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func initializationState(t *testing.T, s *store.Store, tenant, environment string) string { + t.Helper() + value, err := s.GetEnvironment(t.Context(), tenant, environment) + if err != nil { + t.Fatal(err) + } + return value.Initialization +} + +func awaitInitialization(t *testing.T, s *store.Store, tenant, environment, state string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if initializationState(t, s, tenant, environment) == state { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("initialization did not reach %s", state) +} + +func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *testing.T) { + for _, outcome := range []string{"completed", "failed", "unknown", "unavailable", "revoked"} { + t.Run(outcome, func(t *testing.T) { + _, pool := store.NewManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{7}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + principal := store.FixtureExecutorPrincipal(t, s, uuid.NewString()) + session, err := s.CreateSession(t.Context(), principal.TenantID, store.CreateSessionInput{ + Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/work"}}`), + InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, + Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Env: map[string]string{"EXPLICIT": "value"}, Commands: []store.SetupCommand{{Command: "touch setup"}}, CapabilityDirectories: []string{"/home/user/capabilities"}}, + }) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + key, err := s.IssueExecutorCredential(t.Context(), principal, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + enrolled, err := s.EnrollRuntime(t.Context(), environment.ID, device.HashCredential(key.Token)) + if err != nil { + t.Fatal(err) + } + registry := gateway.NewRegistry() + handler := gateway.NewHandler(gateway.HandlerConfig{Authenticator: gateway.NewAuthenticator(s), Registry: registry}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + defer server.Close() + worker, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: registry}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Error(err) + } + }() + var mu sync.Mutex + var actions []string + peer := &initializationPeer{unavailable: outcome == "unavailable"} + peer.setRuntimeGateway(t, "ws"+strings.TrimPrefix(server.URL, "http"), registry) + peer.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { + if request.EnvironmentID != environment.ID || request.SessionID != session.ID { + t.Error("wrong authorization binding") + } + action := request.Action + if request.Initialization != nil { + action = request.Initialization.Action + } + if action == "file" && !bytes.Equal(data, []byte("frozen")) { + t.Error("file snapshot changed") + } + mu.Lock() + actions = append(actions, action) + mu.Unlock() + if outcome == "revoked" { + if err := s.RevokeDevice(t.Context(), principal.TenantID, enrolled.DeviceID); err != nil { + t.Error(err) + } + return completedInitialization(request, data) + } + if outcome != "completed" { + return proto.RuntimePrepareResultPayload{Outcome: outcome, ErrorCode: "runtime_preparation_unconfirmed"} + } + return completedInitialization(request, data) + } + time.Sleep(350 * time.Millisecond) + if initializationState(t, s, principal.TenantID, environment.ID) != "pending" { + t.Fatal("unconnected preparation was consumed") + } + bootstrap := sandbox.Bootstrap{DeviceID: enrolled.DeviceID, Credential: key.Token} + if err := peer.connect(bootstrap); err != nil { + t.Fatal(err) + } + want := "complete" + if outcome != "completed" { + want = "failed" + } + awaitInitialization(t, s, principal.TenantID, environment.ID, want) + if _, err := s.GetRuntimeAllocation(t.Context(), principal.TenantID, environment.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("self-hosted preparation fabricated allocation", err) + } + if outcome == "completed" { + mu.Lock() + got := strings.Join(actions, ",") + mu.Unlock() + if got != "file,configure,skill,setup,finalize" { + t.Fatal("common preparation order", got) + } + if err := peer.connect(bootstrap); err != nil { + t.Fatal(err) + } + time.Sleep(350 * time.Millisecond) + if peer.writes.Load() != 5 { + t.Fatal("reconnect reinstalled snapshot", peer.writes.Load()) + } + } else { + expected := int32(1) + if outcome == "unavailable" { + expected = 0 + } + if peer.writes.Load() != expected { + t.Fatal("failed, unavailable or revoked effect replayed", peer.writes.Load()) + } + value, err := s.GetSession(t.Context(), principal.TenantID, session.ID) + if err != nil || value.EnvironmentFailure == nil { + t.Fatal("failure lost shared Session state", err) + } + } + }) + } +} + +// Revocation can commit after the worker observes a connected peer but before +// it claims preparation. It must remain a per-Environment admission result. +func TestEnvironmentInitializationRevocationBeforeClaim(t *testing.T) { + s, _ := store.NewManagedTestStore(t) + principal := store.FixtureExecutorPrincipal(t, s, uuid.NewString()) + create := func() store.EnvironmentInitialization { + t.Helper() + session, err := s.CreateSession(t.Context(), principal.TenantID, store.CreateSessionInput{ + Creator: principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/work"}}`), + InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/input", Data: []byte("frozen")}}, + }) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + key, err := s.IssueExecutorCredential(t.Context(), principal, uuid.NewString(), environment.ID) + if err != nil { + t.Fatal(err) + } + enrolled, err := s.EnrollRuntime(t.Context(), environment.ID, device.HashCredential(key.Token)) + if err != nil { + t.Fatal(err) + } + return store.EnvironmentInitialization{EnvironmentID: environment.ID, SessionID: session.ID, TenantID: principal.TenantID, DeviceID: enrolled.DeviceID, State: "pending", Engine: "codex"} + } + revoked, other := create(), create() + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + defer lease.Close(context.Background()) + owned := lease.Store() + if err := s.RevokeDevice(t.Context(), principal.TenantID, revoked.DeviceID); err != nil { + t.Fatal(err) + } + if err := owned.ClaimEnvironmentInitialization(t.Context(), revoked); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("revocation escaped normal admission handling: %v", err) + } + stale := other + stale.DeviceID = uuid.NewString() + if err := owned.ClaimEnvironmentInitialization(t.Context(), stale); !errors.Is(err, store.ErrTurnConflict) { + t.Fatalf("stale binding escaped normal admission handling: %v", err) + } + if initializationState(t, s, other.TenantID, other.EnvironmentID) != "pending" { + t.Fatal("stale claim changed preparation state") + } + if err := owned.ClaimEnvironmentInitialization(t.Context(), other); err != nil { + t.Fatal("unrelated Environment could not prepare", err) + } + if err := owned.CompleteEnvironmentInitialization(t.Context(), other); err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go index 55c1d52ec..bd4afb903 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -45,7 +45,7 @@ func (s EnvironmentSetup) Validate() error { } func (s EnvironmentSetup) validate(installed bool) error { - if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateDirectories(s.CapabilityDirectories) != nil { + if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateSourceDirectories(s.CapabilityDirectories) != nil { return ErrInvalidInput } ordinary := s diff --git a/services/agents-api/internal/store/environments.go b/services/agents-api/internal/store/environments.go index 761aad8f9..451861722 100644 --- a/services/agents-api/internal/store/environments.go +++ b/services/agents-api/internal/store/environments.go @@ -16,12 +16,13 @@ import ( // Environment retains execution ownership; its configuration is an internal snapshot, not a public response. type Environment struct { - ID string - SessionID string - TenantID string - Status string - CreatedAt time.Time - Configuration json.RawMessage + Initialization string + ID string + SessionID string + TenantID string + Status string + CreatedAt time.Time + Configuration json.RawMessage } func createSessionEnvironment(ctx context.Context, q *sqlc.Queries, session sqlc.Session) error { @@ -83,6 +84,6 @@ func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration return Environment{ ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), TenantID: uuid.UUID(tenant.Bytes).String(), Status: row.Status, - CreatedAt: row.CreatedAt.Time, Configuration: configuration, + Initialization: row.Initialization, CreatedAt: row.CreatedAt.Time, Configuration: configuration, }, nil } diff --git a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go index d2566bacd..bb5590ec1 100644 --- a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go +++ b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go @@ -129,11 +129,11 @@ func hostedFailureSession(t *testing.T, s *store.Store, tenant string, input sto func failHostedInitialization(t *testing.T, s *store.Store, tenant string, environment store.Environment, p *hostedFailureProvider) { t.Helper() key := uuid.NewString() - w, _ := managedWorker(t, s, key, p) + w, _ := managedWorkerMode(t, s, key, p, false, true) if _, err := w.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { t.Fatal(err) } - reconcileManagedState(t, w, s, tenant, environment.ID, "released") + awaitInitialization(t, s, tenant, environment.ID, "failed") } // H1/H2/H3/H4: one transaction records the Environment failure, an error event @@ -186,8 +186,8 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: test.p.fail, skip: test.p.skip, result: test.p.result, err: test.p.err} failHostedInitialization(t, s, tenant, environment, p) - if !reflect.DeepEqual(p.steps, test.steps) || p.kills != 1 || p.commandCalls.Load() != 0 { - t.Fatal("failed initialization continued or was not reclaimed", p.steps, p.kills) + if !reflect.DeepEqual(p.steps, test.steps) || p.kills != 0 || p.commandCalls.Load() != 0 { + t.Fatal("failed initialization continued or reclaimed compute", p.steps, p.kills) } read, err := s.GetSession(t.Context(), tenant, session.ID) @@ -199,6 +199,11 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { t.Fatal("Session list", page, err) } events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + // Transport may connect while initialization is still running. + if len(events) > 0 && events[0].Event.Type == "agent.session.environment.connected" { + events = events[1:] + } + if err != nil || len(events) != 3 { t.Fatal("failure events", events, err) } @@ -287,7 +292,7 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) t.Cleanup(func() { slog.SetDefault(previous) }) - w, _ := managedWorker(t, s, key, p) + w, _ := managedWorkerMode(t, s, key, p, false, true) auth, err := newTestAuthenticator([]testAPIKey{ {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "tenant-b", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, @@ -333,7 +338,7 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { if _, err := w.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { t.Fatal(err) } - reconcileManagedState(t, w, s, tenant, environment.ID, "released") + awaitInitialization(t, s, tenant, environment.ID, "failed") reason := `Failed to provision environment: script "setup_commands[0]" failed with exit code 3` read, err := s.GetSession(t.Context(), tenant, session.ID) @@ -363,6 +368,10 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { t.Fatal("invalid frame", line) } bodies = append(bodies, data) + if frame["type"] == "agent.session.environment.connected" && len(frames) == 0 && len(names) == 1 { + names = nil + continue + } frames = append(frames, frame) } live.ended(t, 5*time.Second) @@ -418,7 +427,7 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { } } // Logs were captured (the failed step is logged) and hold no output either. - if logged := logs.String(); !strings.Contains(logged, "managed Runtime file initialization incomplete") || strings.Contains(logged, hostedFailureCanary) { + if logged := logs.String(); !strings.Contains(logged, "Environment preparation failed") || strings.Contains(logged, hostedFailureCanary) { t.Fatal("log capture", logged) } } diff --git a/services/agents-api/internal/store/provisioning_failure_detail.go b/services/agents-api/internal/store/provisioning_failure_detail.go index cd18ef33c..baada5920 100644 --- a/services/agents-api/internal/store/provisioning_failure_detail.go +++ b/services/agents-api/internal/store/provisioning_failure_detail.go @@ -25,7 +25,7 @@ func (f ProvisioningFailureDetail) sanitized() *ProvisioningFailureDetail { result.Index = &value } case ProvisioningPythonPackages, ProvisioningNPMPackages: - case ProvisioningInitialFile, ProvisioningSkill: + case ProvisioningInitialFile, ProvisioningSkill, ProvisioningHarness: value := *f.Step result.Step = &value return result diff --git a/services/agents-api/internal/store/runtime_allocation_state.go b/services/agents-api/internal/store/runtime_allocation_state.go index 4fbf54fee..8527813b8 100644 --- a/services/agents-api/internal/store/runtime_allocation_state.go +++ b/services/agents-api/internal/store/runtime_allocation_state.go @@ -44,13 +44,6 @@ func (s *Store) RequestRuntimeCleanup(ctx context.Context, owner RuntimeAllocati return s.requestRuntimeCleanup(ctx, owner, provisioningFailureReason, nil, false) } -// FailRuntimeInitialization is RequestRuntimeCleanup after a confirmed failed -// initialization step: the step's safe reason becomes the Session error. Deleted -// Sessions, expired and already terminal Environments keep their existing outcome. -func (s *Store) FailRuntimeInitialization(ctx context.Context, owner RuntimeAllocation, failure ProvisioningFailure) (RuntimeAllocation, error) { - return s.requestRuntimeCleanup(ctx, owner, failure.reason(), failure.detail(), false) -} - // ReleaseAbsentRuntimeCreation consumes provider proof that the original attempt // is settled and owns no resources. Authority revocation and release commit together. func (s *Store) ReleaseAbsentRuntimeCreation(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { diff --git a/services/agents-api/internal/store/runtime_allocations.go b/services/agents-api/internal/store/runtime_allocations.go index 7edd12ec8..6fc146545 100644 --- a/services/agents-api/internal/store/runtime_allocations.go +++ b/services/agents-api/internal/store/runtime_allocations.go @@ -26,7 +26,6 @@ type RuntimeAllocation struct { ComputeWakeRequested bool ComputeRetainedUntil *time.Time ID, EnvironmentID, SessionID, TenantID, DeviceID, ProviderKey string - Initialization string State string CreateSettled, SessionDeleted, Replayed, Expired bool CreatedAt, KeptAt time.Time @@ -217,7 +216,7 @@ func runtimeAllocationFromRow(row sqlc.RuntimeAllocation, session, tenant pgtype ID: uuid.UUID(row.ID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), SessionID: uuid.UUID(session.Bytes).String(), TenantID: uuid.UUID(tenant.Bytes).String(), DeviceID: uuid.UUID(row.DeviceID.Bytes).String(), ProviderKey: uuid.UUID(row.ProviderKey.Bytes).String(), - Initialization: row.Initialization, State: row.State, CreateSettled: row.CreateSettled, SessionDeleted: deleted.Valid, Expired: expired, + State: row.State, CreateSettled: row.CreateSettled, SessionDeleted: deleted.Valid, Expired: expired, CreatedAt: row.CreatedAt.Time, KeptAt: row.KeptAt.Time, } } diff --git a/services/agents-api/internal/store/runtime_capabilities_pending_test.go b/services/agents-api/internal/store/runtime_capabilities_pending_test.go index 6c7265fbd..2cee8782d 100644 --- a/services/agents-api/internal/store/runtime_capabilities_pending_test.go +++ b/services/agents-api/internal/store/runtime_capabilities_pending_test.go @@ -34,9 +34,9 @@ func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { } provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} key := uuid.NewString() - worker, _ := managedWorker(t, s, key, provider) + worker, _ := managedWorkerMode(t, s, key, provider, false, true) owner, err := worker.ProvisionEnvironment(t.Context(), tenant, env.ID, key) - if err != nil || owner.Initialization != "pending" { + if err != nil || initializationState(t, s, owner.TenantID, owner.EnvironmentID) != "pending" { t.Fatal(owner, err) } for range 4 { @@ -45,38 +45,10 @@ func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { } } owner, err = s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || owner.Initialization != "pending" || owner.State != "running" || provider.writes.Load() != 0 || provider.kills != 0 { + if err != nil || initializationState(t, s, owner.TenantID, owner.EnvironmentID) != "pending" || owner.State != "running" || provider.writes.Load() != 0 || provider.kills != 0 { t.Fatal("missing socket consumed initialization or requested cleanup", owner, err, provider.writes.Load(), provider.kills) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { t.Fatal("ordinary readiness gate bypassed", err) } } - -func TestSelfHostedCapabilityConfigurationNeedsNoManagedSetup(t *testing.T) { - s, pool := store.NewManagedTestStore(t) - tenant := uuid.NewString() - configuration := json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/project","capability_directories":["/opt/skills"]}}`) - session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: configuration}) - if err != nil { - t.Fatal(err) - } - environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) - if err != nil { - t.Fatal(err) - } - var frozen struct { - Workspace string `json:"workspace_directory"` - Directories []string `json:"capability_directories"` - } - if json.Unmarshal(environment.Configuration, &frozen) != nil || frozen.Workspace != "/home/user/project" || len(frozen.Directories) != 1 || frozen.Directories[0] != "/opt/skills" { - t.Fatal("frozen paths lost") - } - var setupCount int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_setups WHERE session_id=$1", session.ID).Scan(&setupCount); err != nil || setupCount != 0 { - t.Fatal("self-hosted initialization row created", setupCount, err) - } - if _, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("self-hosted managed allocation created", err) - } -} diff --git a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go b/services/agents-api/internal/store/runtime_compute_lifecycle_test.go index a5deba40b..e2665e687 100644 --- a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_compute_lifecycle_test.go @@ -183,6 +183,11 @@ func (p *fakeCheckpointProvider) connect(ctx context.Context, b sandbox.Bootstra if err != nil { return err } + heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true}}}) + if err := conn.WriteJSON(heartbeat); err != nil { + conn.Close() + return err + } p.mu.Lock() p.peers[b.AllocationID] = conn p.mu.Unlock() diff --git a/services/agents-api/internal/store/runtime_environment_terminal.go b/services/agents-api/internal/store/runtime_environment_terminal.go index 7243ae082..82203b5ba 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal.go +++ b/services/agents-api/internal/store/runtime_environment_terminal.go @@ -11,7 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -// provisioningFailureReason is the safe reason for a hosted Environment that +// provisioningFailureReason is the safe reason for an Environment that // failed without a confirmed failed step: timeouts, unknown effects, missing or // old receipts, bootstrap rejection and Core restart during initialization. const provisioningFailureReason = "Failed to provision environment: initialization did not complete" @@ -23,9 +23,10 @@ const ( ProvisioningNPMPackages = "npm" ProvisioningInitialFile = "file" ProvisioningSkill = "skill" + ProvisioningHarness = "harness" ) -// ProvisioningFailure identifies a confirmed failed hosted initialization step. +// ProvisioningFailure identifies a confirmed failed initialization step. // It cannot carry Runtime output: Step selects a fixed label, Index is the setup // command position and ExitCode is the Runtime-reported status (0 when absent). type ProvisioningFailure struct { @@ -51,13 +52,15 @@ func (f ProvisioningFailure) reason() string { return fmt.Sprintf("Failed to provision environment: script %q failed with exit code %d", label, f.ExitCode) case f.Step == ProvisioningInitialFile: return "Failed to provision environment: initial file installation failed" + case f.Step == ProvisioningHarness: + return "Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session." case f.Step == ProvisioningSkill: return "Failed to provision environment: Skill installation failed" } return provisioningFailureReason } -// EnvironmentFailure is a hosted Environment's recorded provisioning failure. It +// EnvironmentFailure is an Environment's recorded provisioning failure. It // makes the Session failed with this reason and last activity time. type EnvironmentFailure struct { Reason string `json:"reason"` @@ -88,7 +91,7 @@ func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current s } terminal := row.Environment.Status == "expired" || row.Environment.Status == "failed" if !terminal && !current.Expired { - return failHostedEnvironment(ctx, q, row, current.SessionID, reason, detail, cancel) + return failEnvironment(ctx, q, row, current.SessionID, reason, detail, cancel) } return withEnvironmentInputActivity(ctx, q, current.SessionID, func() error { if !terminal { @@ -103,12 +106,12 @@ func terminateRuntimeEnvironment(ctx context.Context, q *sqlc.Queries, current s }) } -// failHostedEnvironment records, in the caller's transaction and in the observed +// failEnvironment records, in the caller's transaction and in the observed // official order, agent.session.environment.failed, an error event carrying the // safe reason, then one agent.session.failed snapshot. The snapshot captures the // settled input activity, Usage and the failure, matching later Session reads. // Pending input settles as failed exactly as before. -func failHostedEnvironment(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow, session pgtype.UUID, reason string, detail *ProvisioningFailureDetail, cancel func() error) error { +func failEnvironment(ctx context.Context, q *sqlc.Queries, row sqlc.GetSessionEnvironmentRow, session pgtype.UUID, reason string, detail *ProvisioningFailureDetail, cancel func() error) error { var rawDetail []byte if detail != nil { var err error diff --git a/services/agents-api/internal/store/runtime_initialization.go b/services/agents-api/internal/store/runtime_initialization.go index 6028efbeb..30d6a7870 100644 --- a/services/agents-api/internal/store/runtime_initialization.go +++ b/services/agents-api/internal/store/runtime_initialization.go @@ -17,20 +17,8 @@ func (s *Store) requireInitializedEnvironment(ctx context.Context, tenant, sessi if err != nil { return err } - if !ready.Valid || !ready.Bool { + if !ready { return ErrNotFound } return nil } - -func (s *Store) ClaimRuntimeInitialization(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { - return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { - return q.ClaimRuntimeInitialization(ctx, row.ID) - }) -} - -func (s *Store) CompleteRuntimeInitialization(ctx context.Context, owner RuntimeAllocation) (RuntimeAllocation, error) { - return s.mutateRuntimeAllocation(ctx, owner, true, func(ctx context.Context, q *sqlc.Queries, row sqlc.RuntimeAllocation) (sqlc.RuntimeAllocation, error) { - return q.CompleteRuntimeInitialization(ctx, row.ID) - }) -} diff --git a/services/agents-api/internal/store/runtime_initialization_peer_test.go b/services/agents-api/internal/store/runtime_initialization_peer_test.go index cdb9f5368..4f63e44ca 100644 --- a/services/agents-api/internal/store/runtime_initialization_peer_test.go +++ b/services/agents-api/internal/store/runtime_initialization_peer_test.go @@ -26,6 +26,7 @@ type initializationPeer struct { writes atomic.Int32 commandCalls atomic.Int32 deferred bool + unavailable bool bootstrap sandbox.Bootstrap } @@ -42,6 +43,11 @@ func (p *initializationPeer) connect(b sandbox.Bootstrap) error { return err } p.t.Cleanup(func() { _ = c.Close() }) + heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: !p.unavailable}}}) + if err := c.WriteJSON(heartbeat); err != nil { + return err + } + go func() { transfer := initializationTransfer{peer: p} for { diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index 2dc1fb20e..09089c09d 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -9,7 +9,9 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "reflect" "strings" + "sync" "testing" + "time" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -35,7 +37,7 @@ func (p *initializingProvider) RunCommand(ctx context.Context, r sandbox.Referen return p.initializationPeer.RunCommand(ctx, r, c) } -func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { +func TestEnvironmentInitializationCompletionUnknownAndRestart(t *testing.T) { for _, mode := range []string{"complete", "restart", "uncertain", "setup-complete", "setup-restart", "setup-uncertain"} { t.Run(mode, func(t *testing.T) { setupOnly := strings.HasPrefix(mode, "setup-") @@ -62,106 +64,74 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { if err != nil { t.Fatal(err) } - p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} - key := uuid.NewString() - w, stop := managedWorker(t, s, key, p) - owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) - if err != nil || owner.Initialization != "pending" { - t.Fatal("initialization ownership", owner, err) - } - credential, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID) - if err != nil || !ok || credential.ID != owner.DeviceID { - t.Fatal("pending initialization blocks daemon authentication") + if mode == "restart" { + if _, err := pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", env.ID); err != nil { + t.Fatal(err) + } } - targetCredential := p.credential - lastStepGets := 0 + p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} p.apply = func(_ proto.RuntimePreparePayload, _ []byte) proto.RuntimePrepareResultPayload { - p.mu.Lock() - defer p.mu.Unlock() - if p.gets-lastStepGets < 33 { - t.Fatal("initialization advanced before a full allocation scan", p.gets-lastStepGets) - } - lastStepGets = p.gets if _, err := s.GetSessionDevice(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("pending file access", err) + t.Error("premature file access", err) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatal("premature native preparation", err) + t.Error("premature execution", err) } if mode == "uncertain" { return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} } return completedInitialization(proto.RuntimePreparePayload{}, nil) } - // A full page of other allocations is serviced between initialization steps. - for range 32 { - otherTenant, _, otherEnv := managedSession(t, s) - if _, err := w.ProvisionEnvironment(t.Context(), otherTenant, otherEnv.ID, key); err != nil { - t.Fatal(err) + key := uuid.NewString() + w, stop := managedWorkerMode(t, s, key, p, false, true) + if mode == "restart" { + awaitInitialization(t, s, tenant, env.ID, "failed") + if p.writes.Load() != 0 { + t.Fatal("recovered unknown operation replayed") } + return } - // A missing socket must leave every kind of initialization unclaimed. - for range 3 { - if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { - t.Fatal(err) - } + owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil { + t.Fatal(err) + } + if _, ok, err := s.GetDeviceCredential(t.Context(), owner.DeviceID); err != nil || !ok { + t.Fatal("preparation blocked authentication", err) } - pending, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || pending.Initialization != "pending" || p.writes.Load() != 0 { - t.Fatal("missing peer claimed initialization", pending, err) + time.Sleep(350 * time.Millisecond) + if initializationState(t, s, tenant, env.ID) != "pending" || p.writes.Load() != 0 { + t.Fatal("missing socket consumed initialization") } p.deferred = false - if err := p.connect(sandbox.Bootstrap{DeviceID: owner.DeviceID, Credential: targetCredential}); err != nil { + if err := p.connect(sandbox.Bootstrap{DeviceID: owner.DeviceID, Credential: p.credential}); err != nil { t.Fatal(err) } - for n := 0; int(p.writes.Load()) == 0 && n < 100; n++ { - if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { - t.Fatal(err) - } - } - if int(p.writes.Load()) != 1 { - t.Fatal("initialization did not perform one bounded file step", int(p.writes.Load())) - } - afterFirst := p.gets - if mode == "restart" { - stop() - w, _ = managedWorker(t, s, key, p) + want := "complete" + if mode == "uncertain" { + want = "failed" } + awaitInitialization(t, s, tenant, env.ID, want) if mode == "complete" { - for n := 0; int(p.writes.Load()) < expectedSteps && n < 100; n++ { - if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { - t.Fatal(err) - } - } - got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || got.Initialization != "complete" || int(p.writes.Load()) != expectedSteps || p.gets <= afterFirst { - t.Fatal("completion or maintenance", got, err, int(p.writes.Load())) + if int(p.writes.Load()) != expectedSteps { + t.Fatal("missing operations", p.writes.Load()) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { - t.Fatal("ready execution still blocked", err) + t.Fatal("completed preparation blocked", err) } stop() - w, _ = managedWorker(t, s, key, p) - for range 4 { - if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { - t.Fatal(err) - } - } + _, _ = managedWorkerMode(t, s, key, p, false, true) + time.Sleep(350 * time.Millisecond) if int(p.writes.Load()) != expectedSteps { - t.Fatal("completed initialization replayed") - } - } else { - reconcileManagedState(t, w, s, tenant, env.ID, "released") - if int(p.writes.Load()) != 1 || p.kills != 1 { - t.Fatal("uncertain initialization replayed or released twice", int(p.writes.Load()), p.kills) - } - failed, err := s.GetEnvironment(t.Context(), tenant, env.ID) - if err != nil || failed.Status != "failed" { - t.Fatal("failed initialization exposed", failed, err) + t.Fatal("completed preparation replayed") } + } else if p.writes.Load() != 1 { + t.Fatal("unknown operation replayed", p.writes.Load()) } - if p.commandCalls.Load() != 0 { - t.Fatal("initialization invoked Provider.RunCommand") + p.mu.Lock() + kills := p.kills + p.mu.Unlock() + if kills != 0 || p.commandCalls.Load() != 0 { + t.Fatal("preparation changed compute lifecycle", kills, p.commandCalls.Load()) } }) } @@ -191,6 +161,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { }) provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} var actions []string + var actionsMu sync.Mutex provider.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { if request.SessionID != session.ID || request.EnvironmentID != environment.ID { t.Error("Runtime identity changed") @@ -202,32 +173,25 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { if action == "file" && !bytes.Equal(data, fileBody) { t.Error("initial bytes changed") } + actionsMu.Lock() actions = append(actions, action) + actionsMu.Unlock() return completedInitialization(request, data) } key := uuid.NewString() - worker, _ := managedWorker(t, s, key, provider) + worker, _ := managedWorkerMode(t, s, key, provider, false, true) if _, err := worker.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { t.Fatal(err) } - for range 30 { - if err := worker.ReconcileManagedRuntimes(t.Context()); err != nil { - t.Fatal(err) - } - allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) - if err != nil { - t.Fatal(err) - } - if allocation.Initialization == "complete" { - break - } - } + awaitInitialization(t, s, tenant, environment.ID, "complete") + actionsMu.Lock() + defer actionsMu.Unlock() expected := []string{"file", "configure", "skill", "plugin", "npm", "python", "setup", "finalize"} if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) } allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) - if err != nil || allocation.Initialization != "complete" { + if err != nil || initializationState(t, s, allocation.TenantID, allocation.EnvironmentID) != "complete" { t.Fatal("initialization incomplete", allocation, err) } } diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go index 45ee941e1..b8374d62a 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -76,7 +76,7 @@ func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.SandboxPr return managedWorkerMode(t, s, key, p, false) } -func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider, maintenance bool) (*execution.Worker, func()) { +func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider, maintenance bool, run ...bool) (*execution.Worker, func()) { t.Helper() registry := gateway.NewRegistry() if peer, ok := p.(interface { @@ -91,6 +91,22 @@ func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Sandb if err != nil { t.Fatal(err) } + if len(run) > 0 && run[0] { + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- w.Run(ctx) }() + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Error(err) + } + }) + } + t.Cleanup(stop) + return w, stop + } var once sync.Once stop := func() { once.Do(func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = w.Run(ctx) }) @@ -102,7 +118,7 @@ func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Sandb func managedSession(t *testing.T, s *store.Store) (string, store.Session, store.Environment) { t.Helper() tenant := uuid.NewString() - v, e := s.CreateSession(t.Context(), tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`)})) + v, e := s.CreateSession(t.Context(), tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"enabled"}}}`)})) if e != nil { t.Fatal(e) } diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_test.go index 1fc086b7f..9ac5e812a 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_test.go @@ -113,8 +113,8 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { f.provider.mu.Lock() restores := f.provider.restores f.provider.mu.Unlock() - state := fmt.Sprintf("wake=%s/%s err=%v; deleted=%s/%s err=%v; initialized=%s/%s/%s err=%v; restores=%d writes=%d blocked_returns=%d", wake.State, wake.ComputePhase, e1, deleted.State, deleted.ComputePhase, e2, initialized.State, initialized.Initialization, initialized.ComputePhase, e3, restores, f.provider.writes.Load(), f.provider.returned.Load()) - return e1 == nil && e2 == nil && e3 == nil && wake.ComputePhase == "running" && deleted.State == "released" && initialized.Initialization == "complete" && initialized.ComputePhase == "running", state + state := fmt.Sprintf("wake=%s/%s err=%v; deleted=%s/%s err=%v; initialized=%s/%s/%s err=%v; restores=%d writes=%d blocked_returns=%d", wake.State, wake.ComputePhase, e1, deleted.State, deleted.ComputePhase, e2, initialized.State, initializationState(t, f.store, initialized.TenantID, initialized.EnvironmentID), initialized.ComputePhase, e3, restores, f.provider.writes.Load(), f.provider.returned.Load()) + return e1 == nil && e2 == nil && e3 == nil && wake.ComputePhase == "running" && deleted.State == "released" && initializationState(t, f.store, initialized.TenantID, initialized.EnvironmentID) == "complete" && initialized.ComputePhase == "running", state }) if f.provider.returned.Load() != 0 || f.provider.writes.Load() != 1 { t.Fatalf("A returned early or initialization replayed: returned=%d writes=%d", f.provider.returned.Load(), f.provider.writes.Load()) diff --git a/services/agents-api/internal/store/runtime_worker_recovery_test.go b/services/agents-api/internal/store/runtime_worker_recovery_test.go index 601255d7f..a03171a22 100644 --- a/services/agents-api/internal/store/runtime_worker_recovery_test.go +++ b/services/agents-api/internal/store/runtime_worker_recovery_test.go @@ -16,8 +16,8 @@ import ( func insertWorkerRuntimeAllocation(t *testing.T, pool *pgxpool.Pool, h *dispatchHarness, phase string) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO runtime_allocations(id,environment_id,device_id,provider_key,state,create_settled,initialization,compute_phase,compute_retained_until,deployment_generation) - VALUES($1,$2,$3,$4,'running',true,'complete',$5,clock_timestamp()+interval '1 hour',(SELECT generation FROM runtime_deployment))`, uuid.NewString(), h.device.EnvironmentID, h.device.ID, uuid.NewString(), phase) + _, err := pool.Exec(t.Context(), `INSERT INTO runtime_allocations(id,environment_id,device_id,provider_key,state,create_settled,compute_phase,compute_retained_until,deployment_generation) + VALUES($1,$2,$3,$4,'running',true,$5,clock_timestamp()+interval '1 hour',(SELECT generation FROM runtime_deployment))`, uuid.NewString(), h.device.EnvironmentID, h.device.ID, uuid.NewString(), phase) if err != nil { t.Fatal(err) } @@ -25,7 +25,7 @@ func insertWorkerRuntimeAllocation(t *testing.T, pool *pgxpool.Pool, h *dispatch func runtimeWorkerHarness(t *testing.T) (*dispatchHarness, *pgxpool.Pool) { t.Helper() - h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`), true) + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"enabled"}}}`), true) enableWorkerEnvironment(t, h) _, pool := store.NewTestStore(t) insertWorkerRuntimeAllocation(t, pool, h, "waking") diff --git a/services/agents-api/internal/store/session_diagnostics_test.go b/services/agents-api/internal/store/session_diagnostics_test.go index 24c440e24..07f8a536d 100644 --- a/services/agents-api/internal/store/session_diagnostics_test.go +++ b/services/agents-api/internal/store/session_diagnostics_test.go @@ -217,7 +217,9 @@ func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) { _, _ = pool.Exec(context.Background(), "ALTER TABLE session_events DROP CONSTRAINT IF EXISTS "+constraint) }) failure := ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 2, ExitCode: 7} - if _, err = writer.FailRuntimeInitialization(t.Context(), owner, failure); err == nil { + runtimeSuspensionSQL(t, pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID) + preparation := EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} + if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err == nil { t.Fatal("failure committed without events") } var detail []byte @@ -225,7 +227,7 @@ func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) { t.Fatal("partial failure detail", string(detail), err) } runtimeSuspensionSQL(t, pool, "ALTER TABLE session_events DROP CONSTRAINT "+constraint) - if _, err = writer.FailRuntimeInitialization(t.Context(), owner, failure); err != nil { + if err = writer.FailEnvironmentInitialization(t.Context(), preparation, failure); err != nil { t.Fatal(err) } snap, err := s.GetSessionDiagnosticsSnapshot(t.Context(), tenant, session.ID) diff --git a/services/agents-api/internal/store/template_null_selection_public_test.go b/services/agents-api/internal/store/template_null_selection_public_test.go index 74d08b04b..a7d13f412 100644 --- a/services/agents-api/internal/store/template_null_selection_public_test.go +++ b/services/agents-api/internal/store/template_null_selection_public_test.go @@ -87,7 +87,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { } } }) - if len(receipt.Sessions) != 11 || len(receipt.Expected) != 11 || len(receipt.RejectedKeys) != 8 { + if len(receipt.Sessions) != 9 || len(receipt.Expected) != 9 || len(receipt.RejectedKeys) != 10 { t.Fatalf("incomplete acceptance receipt: sessions=%d expectations=%d rejections=%d", len(receipt.Sessions), len(receipt.Expected), len(receipt.RejectedKeys)) } for label, id := range receipt.Sessions { @@ -143,10 +143,10 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { query string want int }{ - {"SELECT count(*) FROM sessions WHERE tenant_id=$1", 11}, - {"SELECT count(*) FROM environments e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 11}, - {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 11}, - {"SELECT count(*) FROM initial_environment_files f JOIN sessions s ON s.id=f.session_id WHERE s.tenant_id=$1", 11}, + {"SELECT count(*) FROM sessions WHERE tenant_id=$1", 9}, + {"SELECT count(*) FROM environments e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 9}, + {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 9}, + {"SELECT count(*) FROM initial_environment_files f JOIN sessions s ON s.id=f.session_id WHERE s.tenant_id=$1", 9}, {"SELECT count(*) FROM turns t JOIN sessions s ON s.id=t.session_id WHERE s.tenant_id=$1", 0}, {"SELECT count(*) FROM environment_input_reservations e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 0}, } { @@ -161,5 +161,5 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { t.Fatalf("rejected creation persisted: %d %v", count, err) } } - t.Log("eleven selection cases passed SDK/raw HTTP, frozen encrypted bytes, tenant isolation, atomic rejection and reopened retries; no Runtime or model execution") + t.Log("nine supported selection cases and rejected network restrictions passed SDK/raw HTTP, frozen encrypted bytes, tenant isolation, atomic rejection and reopened retries; no Runtime or model execution") } diff --git a/services/agents-api/migrations/000090_environment_initialization.sql b/services/agents-api/migrations/000090_environment_initialization.sql new file mode 100644 index 000000000..ac5354f8d --- /dev/null +++ b/services/agents-api/migrations/000090_environment_initialization.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE environments ADD COLUMN initialization text NOT NULL DEFAULT 'complete' + CHECK (initialization IN ('pending', 'running', 'complete', 'failed')); +ALTER TABLE runtime_allocations DROP COLUMN initialization; + +-- +goose Down +ALTER TABLE runtime_allocations ADD COLUMN initialization text NOT NULL DEFAULT 'complete' + CHECK (initialization IN ('pending', 'running', 'complete')); +ALTER TABLE environments DROP COLUMN initialization; diff --git a/services/agents-api/tests/official_template_composition.py b/services/agents-api/tests/official_template_composition.py index 43874d4c0..7ccc0b956 100644 --- a/services/agents-api/tests/official_template_composition.py +++ b/services/agents-api/tests/official_template_composition.py @@ -96,7 +96,7 @@ def projection(client, session_id, expected_files, expected_packages): populated_files = [inline("/workspace/overlap.txt", "-inline-file"), {"type": "file_id", "path": "/workspace/selected-source.txt", "file_id": source.id}] template_body = { - "name": "composition", "network": {"access": "disabled"}, "files": template_files, + "name": "composition", "network": {"access": "enabled"}, "files": template_files, "env": {"TEMPLATE_ONLY": marker + "-template-env", "SHARED": marker + "-template-shared"}, "setup_commands": [{"command": "printf " + marker + "-template-command", "cwd": "/workspace"}], "packages": packages, diff --git a/services/agents-api/tests/official_template_null_selection.py b/services/agents-api/tests/official_template_null_selection.py index 21fad462a..7e9c92491 100644 --- a/services/agents-api/tests/official_template_null_selection.py +++ b/services/agents-api/tests/official_template_null_selection.py @@ -143,7 +143,7 @@ def create(label, body, selection, sdk_create=False): reference = {"type": "skill_reference", "skill_id": first.id} template_skills = [{**reference, "version": "1", "name": first.name, "description": first.description}] plugin_metadata = [{key: plugin[key] for key in ("type", "name", "description")}] - network = {"access": "restricted", "allowed_domains": ["example.com", "api.example.com"]} + network = {"access": "enabled", "allowed_domains": []} files = [{"type": "file_id", "path": "/workspace/source.txt", "file_id": source.id}] template = api.with_raw_response.create(name="selection", network=network, env={"PRIVATE_SELECTION": marker}, files=files, skills=[reference], plugins=[plugin], capability_directories=["/workspace/template-capabilities"]) @@ -161,7 +161,7 @@ def create(label, body, selection, sdk_create=False): replaced = {"skills": [{key: replacement[key] for key in ("type", "name", "description")}], "plugins": [{key: replacement_plugin[key] for key in ("type", "name", "description")}], "capability_directories": ["/workspace/replacement-capabilities"], - "network": {"access": "restricted", "allowed_domains": ["api.example.com"]}, + "network": {"access": "enabled", "allowed_domains": []}, "skill_digests": [hashlib.sha256(replacement_archive).hexdigest()], "plugin_digests": [hashlib.sha256(replacement_plugin_archive).hexdigest()]} rows = [ @@ -185,7 +185,7 @@ def create(label, body, selection, sdk_create=False): missing = copy.deepcopy(base) missing["environment"]["environment_template_id"] = "00000000-0000-0000-0000-000000000001" assert reject(missing, 404, settings["foreign"]) == denied - reject({**base, "environment": {**base["environment"], "network": {"access": "enabled"}}}, 400) + reject({**base, "environment": {**base["environment"], "network": {"access": "restricted", "allowed_domains": ["example.com"]}}}, 400) reject({**base, "environment": {**base["environment"], "skills": [{"type": "skill_reference", "skill_id": foreign_skill.id}]}}, 404) reject({**base, "environment": {**base["environment"], "capability_directories": ["/outside"]}}, 400) malformed = copy.deepcopy(plugin) @@ -194,8 +194,8 @@ def create(label, body, selection, sdk_create=False): disabled = {"access": "disabled", "allowed_domains": []} api.update(template_id, network={"access": "disabled"}) assert api.update(template_id, name="network omission preserves").network.to_dict() == disabled - create("disabled-omitted", base, {**default, "network": disabled}) - create("disabled-null", {**base, "environment": {**base["environment"], "network": None}}, {**default, "network": disabled}) + reject(base, 400) + reject({**base, "environment": {**base["environment"], "network": None}}, 400) reject({**base, "environment": {**base["environment"], "network": {"access": "enabled"}}}, 400) enabled = {"access": "enabled", "allowed_domains": []} assert api.update(template_id, network=None).network.to_dict() == enabled