From 3d8fa72d3951b053d4ef064977798b4ef0884c38 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 07:09:36 +0000 Subject: [PATCH 01/18] build(controller-proto): pin the Cloud contract with frozen agent configuration Moves third_party/cloud to the cloud#106 head (1be0610) that adds the frozen AgentModelConfiguration to AgentSessionSpec and the Node model adapter capabilities to node registration, and regenerates the client. --- .../internal/v1/ora.cloud.internal.v1.rs | 56 +++++++++++++++++++ third_party/cloud | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/controller-proto/src/gen/ora/cloud/internal/v1/ora.cloud.internal.v1.rs b/crates/controller-proto/src/gen/ora/cloud/internal/v1/ora.cloud.internal.v1.rs index 5be924dc7..f4c6bca4f 100644 --- a/crates/controller-proto/src/gen/ora/cloud/internal/v1/ora.cloud.internal.v1.rs +++ b/crates/controller-proto/src/gen/ora/cloud/internal/v1/ora.cloud.internal.v1.rs @@ -108,6 +108,48 @@ pub struct AgentSessionSpec { /// Workspaces of this Project, newest first, at most 100, fixed when the work item was released. #[prost(message, repeated, tag="8")] pub workspace_directory: ::prost::alloc::vec::Vec, + /// Exact non-secret configuration frozen at run admission; authority is acquired over mTLS. + #[prost(message, optional, tag="9")] + pub agent_configuration: ::core::option::Option, +} +/// Versioned adapter identity, never a runtime token or upstream credential. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AgentModelConfiguration { + #[prost(string, tag="1")] + pub plugin_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub plugin_version: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub target: ::prost::alloc::string::String, + #[prost(string, tag="4")] + pub package_sha256: ::prost::alloc::string::String, + #[prost(uint32, tag="5")] + pub schema_version: u32, + #[prost(string, tag="6")] + pub declaration_fingerprint: ::prost::alloc::string::String, + #[prost(uint32, tag="7")] + pub adapter_version: u32, + #[prost(uint64, tag="8")] + pub configuration_revision: u64, + /// Canonical scalar JSON preserves number lexemes that protobuf Struct would round. + #[prost(string, tag="9")] + pub settings_json: ::prost::alloc::string::String, + #[prost(string, tag="10")] + pub protocol: ::prost::alloc::string::String, + #[prost(message, optional, tag="11")] + pub model: ::core::option::Option, +} +/// Model catalog metadata frozen in the binding, independent of live personal configuration. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AgentBoundModel { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub name: ::prost::alloc::string::String, + #[prost(uint64, tag="3")] + pub context_window: u64, + #[prost(uint64, tag="4")] + pub max_tokens: u64, } /// The only terminal result of a session execution. It is taken over only after every Thread event /// before it, so the Thread's last entry lands before the session is known to have ended. @@ -1100,6 +1142,8 @@ pub struct NodeRecord { /// Dedicated personal-model proxy access was advertised by this Node's handshake. #[prost(bool, tag="9")] pub model_proxy: bool, + #[prost(message, optional, tag="10")] + pub model_adapter: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RegisterNodeRequest { @@ -1119,6 +1163,18 @@ pub struct RegisterNodeRequest { /// False for older Nodes or Nodes without configured model-access mTLS credentials. #[prost(bool, tag="7")] pub model_proxy: bool, + #[prost(message, optional, tag="8")] + pub model_adapter: ::core::option::Option, +} +/// Exact host capability advertised after startup validation, independent of plugin metadata. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ModelAdapterCapabilities { + #[prost(uint32, tag="1")] + pub contract_version: u32, + #[prost(string, tag="2")] + pub target: ::prost::alloc::string::String, + #[prost(string, repeated, tag="3")] + pub protocols: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RegisterNodeResponse { diff --git a/third_party/cloud b/third_party/cloud index 7a1a2aff3..1be061007 160000 --- a/third_party/cloud +++ b/third_party/cloud @@ -1 +1 @@ -Subproject commit 7a1a2aff3d4ffef0cec0ce7a833c9429b444a734 +Subproject commit 1be061007486a412893bf19b6ee7e4530fdf40f5 From 345533522a41a381aa8199d551f3a0270603795a Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:36:54 +0000 Subject: [PATCH 02/18] feat(node-protocol): carry frozen agent configuration and adapter capability A configured session names the exact non-secret configuration Cloud froze at admission next to its model binding; it must describe the session's own plugin release, a supported adapter contract and one JSON object of Settings. HelloAccepted advertises the Node's adapter target, contract and protocols, only beside model_proxy. --- crates/node-db/src/tests/session.rs | 1 + crates/node-protocol/src/domain.rs | 5 + .../node-protocol/src/domain/agent_session.rs | 16 +- .../node-protocol/src/domain/model_adapter.rs | 138 ++++++++++++++++++ crates/node-protocol/src/lib.rs | 29 ++-- crates/node-protocol/src/message/session.rs | 11 +- .../node-protocol/src/message/validation.rs | 8 + .../tests/protocol/agent_session.rs | 99 +++++++++++++ .../node-protocol/tests/protocol/session.rs | 54 +++++++ .../tests/protocol/session/fixtures.rs | 1 + 10 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 crates/node-protocol/src/domain/model_adapter.rs diff --git a/crates/node-db/src/tests/session.rs b/crates/node-db/src/tests/session.rs index 9d45fe75b..291bb6d42 100644 --- a/crates/node-db/src/tests/session.rs +++ b/crates/node-db/src/tests/session.rs @@ -13,6 +13,7 @@ pub(super) fn command() -> StartAgentSessionMessage { execution_id: ExecutionId::new("session-execution"), payload: StartAgentSession { spec: AgentSessionSpec { + agent_configuration: None, node_id: NodeId::new("node"), agent_plugin_id: PluginId::new("official/agent"), agent_plugin_version: PluginVersion::new("1.0.0"), diff --git a/crates/node-protocol/src/domain.rs b/crates/node-protocol/src/domain.rs index ef415dca6..8edd58653 100644 --- a/crates/node-protocol/src/domain.rs +++ b/crates/node-protocol/src/domain.rs @@ -1,6 +1,7 @@ mod agent_session; mod execution; mod git_server; +mod model_adapter; mod plugin; mod repository; mod repository_result; @@ -19,6 +20,10 @@ pub use git_server::{ InvalidGitServerRef, MAX_WORKSPACE_BRANCH_BYTES, SnapshotRef, WorkspaceRef, is_canonical_uuid, is_full_lowercase_commit, is_workspace_branch, }; +pub use model_adapter::{ + BoundModel, FrozenAgentConfiguration, MAX_AGENT_SETTINGS_BYTES, MODEL_ADAPTER_CONTRACT_VERSION, + ModelAdapterCapability, ModelProtocol, +}; pub use plugin::{ InstallPluginsSpec, PluginAgentConfiguration, PluginDownload, PluginExecutionResult, PluginFailureCode, PluginId, PluginInstall, PluginItemOutcome, PluginItemResult, PluginRelease, diff --git a/crates/node-protocol/src/domain/agent_session.rs b/crates/node-protocol/src/domain/agent_session.rs index 205134016..8b049d4c6 100644 --- a/crates/node-protocol/src/domain/agent_session.rs +++ b/crates/node-protocol/src/domain/agent_session.rs @@ -2,8 +2,8 @@ //! history records it streams as non-terminal Thread events. use crate::{ - ExecutionId, MessageValidationError, ModelBindingId, NodeId, NodeRuntimeIdentity, PluginId, - PluginVersion, PriorRevision, TurnId, is_canonical_uuid, + ExecutionId, FrozenAgentConfiguration, MessageValidationError, ModelBindingId, NodeId, + NodeRuntimeIdentity, PluginId, PluginVersion, PriorRevision, TurnId, is_canonical_uuid, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -121,6 +121,10 @@ pub struct AgentSessionSpec { /// Only a binding reference crosses the control protocol; model credentials never do. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_binding_id: Option, + /// Present only for a plugin-owned adapter; the session then also names its model binding, + /// and the plugin, not the Node, turns the binding into CLI configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_configuration: Option, pub git_identity: GitIdentity, pub initial_turn: UserTurn, /// The Revision this session resumes; the Node restores it into the checkout before the agent @@ -159,6 +163,14 @@ impl AgentSessionSpec { field: "model_binding_id", }); } + if let Some(configuration) = &self.agent_configuration { + // An adapter needs an authority to apply; one without a binding could only fall back + // to whatever credentials the host happens to hold. + if self.model_binding_id.is_none() { + return Err(MessageValidationError::InvalidAgentConfiguration); + } + configuration.validate(&self.agent_plugin_id, &self.agent_plugin_version)?; + } self.initial_turn.validate()?; if let Some(prior) = &self.prior_revision { prior.validate()?; diff --git a/crates/node-protocol/src/domain/model_adapter.rs b/crates/node-protocol/src/domain/model_adapter.rs new file mode 100644 index 000000000..0ff15c48a --- /dev/null +++ b/crates/node-protocol/src/domain/model_adapter.rs @@ -0,0 +1,138 @@ +//! Non-secret inputs and host facts of the plugin-owned model adapter path. +//! +//! Only frozen identity and validated Settings cross the control protocol. The temporary token +//! that authorizes model calls is obtained by the Node over its own mTLS identity and never +//! appears in a command, the ledger or a status report. + +use crate::{MessageValidationError, PluginId, PluginVersion, Sha256Digest}; +use serde::{Deserialize, Serialize}; + +/// The only adapter contract this protocol version can describe. +pub const MODEL_ADAPTER_CONTRACT_VERSION: u32 = 1; + +/// Largest frozen Settings document; declarations bound their fields far below this. +pub const MAX_AGENT_SETTINGS_BYTES: usize = 64 * 1024; + +/// Model wire protocols a platform proxy serves to an adapter. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ModelProtocol { + OpenaiCompletions, + AnthropicMessages, +} + +impl ModelProtocol { + /// Parses Cloud's stable protocol spelling; any other name is not a supported protocol. + pub fn parse(value: &str) -> Option { + match value { + "openai-completions" => Some(Self::OpenaiCompletions), + "anthropic-messages" => Some(Self::AnthropicMessages), + _ => None, + } + } + + /// The spelling Cloud and the plugin protocol use for this protocol. + pub fn as_str(self) -> &'static str { + match self { + Self::OpenaiCompletions => "openai-completions", + Self::AnthropicMessages => "anthropic-messages", + } + } +} + +/// Model catalog metadata frozen in the binding at admission, never upstream discovery. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BoundModel { + pub id: String, + pub name: String, + pub context_window: u64, + pub max_tokens: u64, +} + +/// The exact Agent configuration Cloud froze for one run. +/// +/// A retry replays this value; the Node never re-reads a personal default. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FrozenAgentConfiguration { + pub plugin_id: PluginId, + pub plugin_version: PluginVersion, + pub target: String, + pub package_sha256: Sha256Digest, + pub schema_version: u32, + pub declaration_fingerprint: Sha256Digest, + pub adapter_version: u32, + pub configuration_revision: u64, + /// Validated Settings kept as JSON text so numeric lexemes survive every hop unchanged. + pub settings_json: String, + pub protocol: ModelProtocol, + pub model: BoundModel, +} + +impl FrozenAgentConfiguration { + /// Rejects a configuration that does not describe the session's own plugin release. + pub(crate) fn validate( + &self, + plugin_id: &PluginId, + plugin_version: &PluginVersion, + ) -> Result<(), MessageValidationError> { + self.package_sha256.validate()?; + self.declaration_fingerprint.validate()?; + // The settings must be one JSON object: adapters read named fields, never a bare value. + let settings_object = self.settings_json.len() <= MAX_AGENT_SETTINGS_BYTES + && serde_json::from_str::(&self.settings_json) + .is_ok_and(|settings| settings.is_object()); + if self.plugin_id != *plugin_id + || self.plugin_version != *plugin_version + || self.target.is_empty() + || self.schema_version == 0 + || self.adapter_version != MODEL_ADAPTER_CONTRACT_VERSION + || self.configuration_revision == 0 + || !settings_object + || self.model.id.is_empty() + || self.model.name.is_empty() + || self.model.context_window == 0 + || self.model.max_tokens == 0 + { + return Err(MessageValidationError::InvalidAgentConfiguration); + } + Ok(()) + } +} + +/// What a Node can run on the adapter path, advertised in its handshake. +/// +/// It is a fact about the Node binary and its model-access configuration, independent of any +/// plugin's declaration; a Controller dispatches a configured session only on an exact match. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelAdapterCapability { + pub contract_version: u32, + pub target: String, + pub protocols: Vec, +} + +impl ModelAdapterCapability { + /// Requires a known contract, a target and a non-repeating protocol set. + pub(crate) fn validate(&self) -> Result<(), MessageValidationError> { + let mut protocols = self.protocols.clone(); + protocols.sort(); + protocols.dedup(); + if self.contract_version != MODEL_ADAPTER_CONTRACT_VERSION + || self.target.is_empty() + || self.protocols.is_empty() + || protocols.len() != self.protocols.len() + { + return Err(MessageValidationError::InvalidModelAdapterCapability); + } + Ok(()) + } + + /// Whether a session frozen with `configuration` can run on this Node. + pub fn admits(&self, configuration: &FrozenAgentConfiguration) -> bool { + self.contract_version == configuration.adapter_version + && self.target == configuration.target + && self.protocols.contains(&configuration.protocol) + } +} diff --git a/crates/node-protocol/src/lib.rs b/crates/node-protocol/src/lib.rs index d2fea3758..5a00013e3 100644 --- a/crates/node-protocol/src/lib.rs +++ b/crates/node-protocol/src/lib.rs @@ -10,20 +10,21 @@ mod message; pub use message::{ThreadPreview, ThreadPreviewBlock, ThreadPreviewMessage}; pub use domain::{ - AgentSessionEndReason, AgentSessionEnded, AgentSessionResult, AgentSessionSpec, BranchName, - CloneExecutionResult, CloneExecutionSpec, CloneFailed, CloneFailureCode, CloneReady, - CloneResidual, CommitId, ContentBlock, DELIVERY_REF_PREFIX, DeliverRevisionSpec, DeliveryRef, - ExecutionResult, GitIdentity, GitRef, GitRepositoryUrl, InstallPluginsSpec, - InvalidGitRepositoryUrl, InvalidGitServerRef, MAX_THREAD_RECORD_BYTES, - MAX_USER_TURN_TEXT_BYTES, MAX_VISIBLE_TEXT_BYTES, MAX_WORKSPACE_BRANCH_BYTES, - MAX_WORKSPACE_DIRECTORY, MainWorkspaceBinding, NodePath, ObjectKey, ObjectUploadGrant, - PluginAgentConfiguration, PluginDownload, PluginExecutionResult, PluginFailureCode, PluginId, - PluginInstall, PluginItemOutcome, PluginItemResult, PluginRelease, PluginRemoval, - PluginTargetDownload, PluginVersion, PluginsCompleted, PluginsFailed, PluginsFailureCode, - PresignedUrl, PriorRevision, PriorRevisionCommit, REVISION_REF_PREFIX, RemovePluginsSpec, - RepositoryRef, RevisionDelivered, RevisionExecutionResult, RevisionFailed, RevisionFailureCode, - RevisionRef, RevisionUnchanged, Sha256Digest, SnapshotRef, StoredObject, ThreadEvent, - UploadMethod, UserTurn, WorkspaceListing, WorkspaceRef, WorktreeExecutionResult, + AgentSessionEndReason, AgentSessionEnded, AgentSessionResult, AgentSessionSpec, BoundModel, + BranchName, CloneExecutionResult, CloneExecutionSpec, CloneFailed, CloneFailureCode, + CloneReady, CloneResidual, CommitId, ContentBlock, DELIVERY_REF_PREFIX, DeliverRevisionSpec, + DeliveryRef, ExecutionResult, FrozenAgentConfiguration, GitIdentity, GitRef, GitRepositoryUrl, + InstallPluginsSpec, InvalidGitRepositoryUrl, InvalidGitServerRef, MAX_AGENT_SETTINGS_BYTES, + MAX_THREAD_RECORD_BYTES, MAX_USER_TURN_TEXT_BYTES, MAX_VISIBLE_TEXT_BYTES, + MAX_WORKSPACE_BRANCH_BYTES, MAX_WORKSPACE_DIRECTORY, MODEL_ADAPTER_CONTRACT_VERSION, + MainWorkspaceBinding, ModelAdapterCapability, ModelProtocol, NodePath, ObjectKey, + ObjectUploadGrant, PluginAgentConfiguration, PluginDownload, PluginExecutionResult, + PluginFailureCode, PluginId, PluginInstall, PluginItemOutcome, PluginItemResult, PluginRelease, + PluginRemoval, PluginTargetDownload, PluginVersion, PluginsCompleted, PluginsFailed, + PluginsFailureCode, PresignedUrl, PriorRevision, PriorRevisionCommit, REVISION_REF_PREFIX, + RemovePluginsSpec, RepositoryRef, RevisionDelivered, RevisionExecutionResult, RevisionFailed, + RevisionFailureCode, RevisionRef, RevisionUnchanged, Sha256Digest, SnapshotRef, StoredObject, + ThreadEvent, UploadMethod, UserTurn, WorkspaceListing, WorkspaceRef, WorktreeExecutionResult, WorktreeExecutionSpec, WorktreeFacts, WorktreeFailed, WorktreeFailure, WorktreeFailureCode, WorktreePathPolicy, WorktreeReady, WorktreeRemovalFailed, WorktreeRemovalOutcome, WorktreeRemoved, is_canonical_uuid, is_full_lowercase_commit, is_workspace_branch, diff --git a/crates/node-protocol/src/message/session.rs b/crates/node-protocol/src/message/session.rs index a594a4975..f9dda40e4 100644 --- a/crates/node-protocol/src/message/session.rs +++ b/crates/node-protocol/src/message/session.rs @@ -1,7 +1,7 @@ use super::validation::{ MessageValidationError, ValidateMessage, validate_identity, validate_protocol_version, }; -use crate::{ControllerId, NodeRuntimeIdentity, ProtocolVersion}; +use crate::{ControllerId, ModelAdapterCapability, NodeRuntimeIdentity, ProtocolVersion}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -48,6 +48,9 @@ pub struct HelloAccepted { pub selected_version: ProtocolVersion, pub node: NodeRuntimeIdentity, pub capabilities: Vec, + /// Present only beside `model_proxy`; older Nodes omit it and receive no configured session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_adapter: Option, } /// Liveness signal from the current Node incarnation. @@ -155,6 +158,12 @@ impl ValidateMessage for HelloAcceptedMessage { if capabilities.is_empty() { return Err(MessageValidationError::NoExecutionCapabilities); } + if let Some(adapter) = &payload.model_adapter { + if !capabilities.contains(&NodeCapability::ModelProxy) { + return Err(MessageValidationError::InvalidModelAdapterCapability); + } + adapter.validate()?; + } Ok(()) } } diff --git a/crates/node-protocol/src/message/validation.rs b/crates/node-protocol/src/message/validation.rs index 8ccf219cd..50fdbe57e 100644 --- a/crates/node-protocol/src/message/validation.rs +++ b/crates/node-protocol/src/message/validation.rs @@ -56,6 +56,14 @@ pub enum MessageValidationError { "git identity must be a single-line name and an address without spaces or angle brackets" )] InvalidGitIdentity, + #[error( + "agent configuration must describe the session's plugin release on a supported adapter" + )] + InvalidAgentConfiguration, + #[error( + "model adapter capability must name a supported contract, a target and distinct protocols" + )] + InvalidModelAdapterCapability, #[error("user turn must carry non-empty text within the size limit")] InvalidUserTurn, #[error("assistant text projection is oversized or missing after truncation")] diff --git a/crates/node-protocol/tests/protocol/agent_session.rs b/crates/node-protocol/tests/protocol/agent_session.rs index 810692799..021febfd1 100644 --- a/crates/node-protocol/tests/protocol/agent_session.rs +++ b/crates/node-protocol/tests/protocol/agent_session.rs @@ -26,6 +26,7 @@ pub(super) fn start() -> Case { execution_id: ExecutionId::new(EXECUTION), payload: StartAgentSession { spec: AgentSessionSpec { + agent_configuration: None, node_id: NodeId::new("node-1"), agent_plugin_id: PluginId::new("ora.1a2b/claude-code"), agent_plugin_version: PluginVersion::new("1.2.0"), @@ -201,6 +202,104 @@ async fn model_bound_session_round_trips_and_rejects_empty_binding() -> Result<( .await } +/// A frozen adapter configuration crosses with its binding, Settings kept as exact JSON text. +#[tokio::test] +async fn configured_session_round_trips_and_rejects_inconsistent_configuration() +-> Result<(), TestError> { + let mut case = start(); + let Message::Controller(ControllerToNodeMessage::StartAgentSession(command)) = + &mut case.message + else { + panic!("fixture must be a session start") + }; + command.payload.spec.model_binding_id = Some(ModelBindingId::new("binding-1")); + command.payload.spec.agent_configuration = Some(FrozenAgentConfiguration { + plugin_id: PluginId::new("ora.1a2b/claude-code"), + plugin_version: PluginVersion::new("1.2.0"), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: Sha256Digest::new("a".repeat(64)), + schema_version: 2, + declaration_fingerprint: Sha256Digest::new("b".repeat(64)), + adapter_version: MODEL_ADAPTER_CONTRACT_VERSION, + configuration_revision: 3, + settings_json: r#"{"limit":12345678901234567890}"#.into(), + protocol: ModelProtocol::AnthropicMessages, + model: BoundModel { + id: "team/model".into(), + name: "Team model".into(), + context_window: 200_000, + max_tokens: 8192, + }, + }); + case.wire["payload"]["spec"]["model_binding_id"] = json!("binding-1"); + case.wire["payload"]["spec"]["agent_configuration"] = json!({ + "plugin_id": "ora.1a2b/claude-code", + "plugin_version": "1.2.0", + "target": "x86_64-unknown-linux-gnu", + "package_sha256": "a".repeat(64), + "schema_version": 2, + "declaration_fingerprint": "b".repeat(64), + "adapter_version": 1, + "configuration_revision": 3, + "settings_json": r#"{"limit":12345678901234567890}"#, + "protocol": "anthropic-messages", + "model": {"id": "team/model", "name": "Team model", + "context_window": 200_000, "max_tokens": 8192}, + }); + case.assert_wire().await?; + case.assert_round_trip().await?; + let configuration = "/payload/spec/agent_configuration"; + for (path, value) in [ + ( + format!("{configuration}/plugin_id"), + json!("ora.1a2b/other"), + ), + (format!("{configuration}/plugin_version"), json!("1.2.1")), + (format!("{configuration}/target"), json!("")), + (format!("{configuration}/adapter_version"), json!(2)), + (format!("{configuration}/configuration_revision"), json!(0)), + (format!("{configuration}/settings_json"), json!("[1]")), + (format!("{configuration}/settings_json"), json!("{")), + (format!("{configuration}/model/id"), json!("")), + ] { + let mut wire = case.wire.clone(); + replace(&mut wire, &path, value); + reject_semantics( + Peer::Controller, + &wire, + &path, + MessageValidationError::InvalidAgentConfiguration, + ) + .await?; + } + // Without a binding an adapter could only fall back to whatever the host holds. + let mut wire = case.wire.clone(); + wire["payload"]["spec"] + .as_object_mut() + .unwrap() + .remove("model_binding_id"); + reject_semantics( + Peer::Controller, + &wire, + "missing binding", + MessageValidationError::InvalidAgentConfiguration, + ) + .await?; + let mut wire = case.wire.clone(); + replace( + &mut wire, + &format!("{configuration}/package_sha256"), + json!("A".repeat(64)), + ); + reject_semantics( + Peer::Controller, + &wire, + "uppercase digest", + MessageValidationError::InvalidSha256, + ) + .await +} + /// Identity values that would corrupt a Git signature line and unbounded turns never cross. #[tokio::test] async fn start_agent_session_rejects_unsafe_identity_and_turns() -> Result<(), TestError> { diff --git a/crates/node-protocol/tests/protocol/session.rs b/crates/node-protocol/tests/protocol/session.rs index df79d125f..330ed2a3c 100644 --- a/crates/node-protocol/tests/protocol/session.rs +++ b/crates/node-protocol/tests/protocol/session.rs @@ -210,3 +210,57 @@ async fn rejects_missing_and_empty_fields() -> Result<(), TestError> { .await?; Ok(()) } + +/// Adapter facts travel only beside `model_proxy`, with a known contract and distinct protocols. +#[tokio::test] +async fn model_adapter_capability_round_trips_and_requires_model_proxy() -> Result<(), TestError> { + let mut case = fixtures::hello_accepted(); + let Message::Node(NodeToControllerMessage::HelloAccepted(accepted)) = &mut case.message else { + panic!("fixture must be a hello_accepted") + }; + accepted.payload.capabilities = vec![NodeCapability::AgentSession, NodeCapability::ModelProxy]; + accepted.payload.model_adapter = Some(ModelAdapterCapability { + contract_version: MODEL_ADAPTER_CONTRACT_VERSION, + target: "x86_64-unknown-linux-gnu".into(), + protocols: vec![ + ModelProtocol::OpenaiCompletions, + ModelProtocol::AnthropicMessages, + ], + }); + case.wire["payload"]["capabilities"] = json!(["agent_session", "model_proxy"]); + case.wire["payload"]["model_adapter"] = json!({ + "contract_version": 1, + "target": "x86_64-unknown-linux-gnu", + "protocols": ["openai-completions", "anthropic-messages"], + }); + case.assert_wire().await?; + case.assert_round_trip().await?; + for (path, value) in [ + ("/payload/capabilities", json!(["agent_session"])), + ("/payload/model_adapter/contract_version", json!(2)), + ("/payload/model_adapter/target", json!("")), + ("/payload/model_adapter/protocols", json!([])), + ( + "/payload/model_adapter/protocols", + json!(["anthropic-messages", "anthropic-messages"]), + ), + ] { + let mut wire = case.wire.clone(); + replace(&mut wire, path, value); + reject_semantics( + Peer::Node, + &wire, + path, + MessageValidationError::InvalidModelAdapterCapability, + ) + .await?; + } + let mut wire = case.wire.clone(); + replace( + &mut wire, + "/payload/model_adapter/protocols", + json!(["openai-responses"]), + ); + reject_structure(Peer::Node, &wire, "unknown model protocol").await; + Ok(()) +} diff --git a/crates/node-protocol/tests/protocol/session/fixtures.rs b/crates/node-protocol/tests/protocol/session/fixtures.rs index 4939190ad..50c452c42 100644 --- a/crates/node-protocol/tests/protocol/session/fixtures.rs +++ b/crates/node-protocol/tests/protocol/session/fixtures.rs @@ -31,6 +31,7 @@ pub(super) fn hello_accepted() -> Case { HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { + model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: node(), From 4bf2d7c7ced405cc9722840ce2f624beac1897b7 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:36:54 +0000 Subject: [PATCH 03/18] feat(controller): dispatch configured sessions only to matching adapters The Controller maps Cloud's frozen configuration into the Node start, reports the handshake's adapter facts at registration, and claims or retransmits a configured session only to an incarnation whose target, contract and protocols admit it; other work stays queued in Cloud. --- .../src/cloud/agents/mapping.rs | 43 ++++++ .../src/cloud/agents/previews/tests/server.rs | 1 + apps/ora-controller/src/cloud/claim.rs | 2 + apps/ora-controller/src/cloud/fleet.rs | 34 ++++- apps/ora-controller/src/cloud/reports.rs | 14 ++ apps/ora-controller/src/session.rs | 14 +- apps/ora-controller/src/session/control.rs | 11 ++ apps/ora-controller/tests/cloud.rs | 2 +- apps/ora-controller/tests/session.rs | 6 +- .../tests/support/workspace_cloud.rs | 1 + .../tests/support/workspace_cloud/agents.rs | 31 +++++ apps/ora-controller/tests/workspaces.rs | 1 + .../ora-controller/tests/workspaces/agents.rs | 129 ++++++++++++++++++ 13 files changed, 283 insertions(+), 6 deletions(-) diff --git a/apps/ora-controller/src/cloud/agents/mapping.rs b/apps/ora-controller/src/cloud/agents/mapping.rs index c4497b75a..bc3291fce 100644 --- a/apps/ora-controller/src/cloud/agents/mapping.rs +++ b/apps/ora-controller/src/cloud/agents/mapping.rs @@ -36,6 +36,11 @@ pub(in crate::cloud) fn start( checkout_execution_id: ExecutionId::new(spec.checkout_execution_id.clone()), model_binding_id: (!spec.model_binding_id.is_empty()) .then(|| ModelBindingId::new(spec.model_binding_id.clone())), + agent_configuration: spec + .agent_configuration + .as_ref() + .map(configuration) + .transpose()?, git_identity: GitIdentity { name: identity.name.clone(), email: identity.email.clone(), @@ -50,6 +55,42 @@ pub(in crate::cloud) fn start( Ok(command) } +/// Carries Cloud's frozen adapter input verbatim; an unknown protocol or a missing model cannot be +/// guessed, so the input is refused rather than started on another route. +fn configuration( + input: &proto::AgentModelConfiguration, +) -> Result { + let model = input.model.as_ref().ok_or(Error::Conflict)?; + Ok(FrozenAgentConfiguration { + plugin_id: PluginId::new(input.plugin_id.clone()), + plugin_version: PluginVersion::new(input.plugin_version.clone()), + target: input.target.clone(), + package_sha256: Sha256Digest::new(input.package_sha256.clone()), + schema_version: input.schema_version, + declaration_fingerprint: Sha256Digest::new(input.declaration_fingerprint.clone()), + adapter_version: input.adapter_version, + configuration_revision: input.configuration_revision, + settings_json: input.settings_json.clone(), + protocol: ModelProtocol::parse(&input.protocol).ok_or(Error::Conflict)?, + model: BoundModel { + id: model.id.clone(), + name: model.name.clone(), + context_window: model.context_window, + max_tokens: model.max_tokens, + }, + }) +} + +/// Whether a session input carries a frozen adapter configuration, and which. +pub(in crate::cloud) fn frozen_configuration( + input: Option<&proto::ExecutionInput>, +) -> Option<&proto::AgentModelConfiguration> { + match input.and_then(|v| v.spec.as_ref()) { + Some(proto::execution_input::Spec::AgentSession(spec)) => spec.agent_configuration.as_ref(), + Some(_) | None => None, + } +} + /// A session restores its prior Revision from the Git Server location Cloud fixed, so a prior /// Revision whose repository or delivery ref the Node protocol refuses cannot be restored and the /// input is refused rather than started as a fresh session. @@ -198,6 +239,7 @@ mod tests { input: Some(proto::ExecutionInput { spec: Some(proto::execution_input::Spec::AgentSession( proto::AgentSessionSpec { + agent_configuration: None, model_binding_id: String::new(), agent_plugin_id: "official/ora-space.echo".into(), agent_plugin_version: "1.2.3".into(), @@ -274,6 +316,7 @@ mod tests { agent_plugin_version: PluginVersion::new("1.2.3"), checkout_execution_id: ExecutionId::new("clone"), model_binding_id: None, + agent_configuration: None, git_identity: GitIdentity { name: "User".into(), email: "user@example.com".into(), diff --git a/apps/ora-controller/src/cloud/agents/previews/tests/server.rs b/apps/ora-controller/src/cloud/agents/previews/tests/server.rs index fae934aac..bd2816793 100644 --- a/apps/ora-controller/src/cloud/agents/previews/tests/server.rs +++ b/apps/ora-controller/src/cloud/agents/previews/tests/server.rs @@ -114,6 +114,7 @@ impl ExecutionService for Server { input: Some(proto::ExecutionInput { spec: Some(proto::execution_input::Spec::AgentSession( proto::AgentSessionSpec { + agent_configuration: None, agent_plugin_id: "official/ora-space.echo".into(), agent_plugin_version: "1.2.3".into(), checkout_execution_id: "checkout".into(), diff --git a/apps/ora-controller/src/cloud/claim.rs b/apps/ora-controller/src/cloud/claim.rs index 73de2b520..7f0120397 100644 --- a/apps/ora-controller/src/cloud/claim.rs +++ b/apps/ora-controller/src/cloud/claim.rs @@ -90,6 +90,8 @@ pub(super) async fn batch( && (!matches!(item.input.as_ref().and_then(|input| input.spec.as_ref()), Some(proto::execution_input::Spec::AgentSession(spec)) if !spec.model_binding_id.is_empty()) || sandbox.advertises(NodeCapability::ModelProxy)) + && agents::mapping::frozen_configuration(item.input.as_ref()) + .is_none_or(|configuration| sandbox.admits(configuration)) } Targeted::Delivery => sandbox.advertises(NodeCapability::RevisionDelivery), }; diff --git a/apps/ora-controller/src/cloud/fleet.rs b/apps/ora-controller/src/cloud/fleet.rs index c32c0efe5..c238d1db1 100644 --- a/apps/ora-controller/src/cloud/fleet.rs +++ b/apps/ora-controller/src/cloud/fleet.rs @@ -118,6 +118,8 @@ pub(super) struct Sandbox { pub(super) gate: sync::Mutex, unresolved: Mutex>, capabilities: Mutex>, + /// The adapter facts of the same handshake; only an exact match runs a configured session. + model_adapter: Mutex>, /// Cloud's Node records of this sandbox, from the latest snapshot. known: Mutex>, } @@ -148,6 +150,27 @@ impl Sandbox { .contains(&NodeCapability::ModelProxy) } + /// Registration reports the adapter facts of the same handshake as `model_proxy`. + pub(super) fn advertised_model_adapter(&self) -> Option { + self.model_adapter + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Whether the live session can run a session frozen with `configuration`. Cloud fences + /// the same match; checking here keeps an incompatible item queued instead of refused. + pub(super) fn admits(&self, configuration: &proto::AgentModelConfiguration) -> bool { + let Some(adapter) = self.advertised_model_adapter() else { + return false; + }; + self.connected() + && adapter.contract_version == configuration.adapter_version + && adapter.target == configuration.target + && ModelProtocol::parse(&configuration.protocol) + .is_some_and(|protocol| adapter.protocols.contains(&protocol)) + } + /// Whether a session is established right now. pub(super) fn connected(&self) -> bool { self.identity.borrow().is_some() @@ -196,11 +219,19 @@ impl Sandbox { } impl SessionObserver for Sandbox { - fn capabilities(&self, capabilities: &[NodeCapability]) { + fn capabilities( + &self, + capabilities: &[NodeCapability], + model_adapter: Option<&ModelAdapterCapability>, + ) { *self .capabilities .lock() .unwrap_or_else(PoisonError::into_inner) = capabilities.to_vec(); + *self + .model_adapter + .lock() + .unwrap_or_else(PoisonError::into_inner) = model_adapter.cloned(); } fn established(&self, node: &NodeRuntimeIdentity) { self.identity.send_replace(Some(node.clone())); @@ -299,6 +330,7 @@ impl Fleet { gate: sync::Mutex::new(Gate::Open), unresolved: Mutex::default(), capabilities: Mutex::default(), + model_adapter: Mutex::default(), known: Mutex::new(records), }); let (stop, stopping) = watch::channel(false); diff --git a/apps/ora-controller/src/cloud/reports.rs b/apps/ora-controller/src/cloud/reports.rs index b8fdac1f4..c6f95bb29 100644 --- a/apps/ora-controller/src/cloud/reports.rs +++ b/apps/ora-controller/src/cloud/reports.rs @@ -99,6 +99,19 @@ async fn register( let epoch = store.epoch()?; let binding = &sandbox.binding; let model_proxy = sandbox.advertised_model_proxy(); + let model_adapter = + sandbox + .advertised_model_adapter() + .map(|adapter| proto::ModelAdapterCapabilities { + contract_version: adapter.contract_version, + target: adapter.target, + protocols: adapter + .protocols + .into_iter() + .map(|protocol| protocol.as_str().to_string()) + .collect(), + }); + let model_adapter = &model_adapter; let response = fault::write(|submission_id| async move { let request = store.request(proto::RegisterNodeRequest { submission_id, @@ -111,6 +124,7 @@ async fn register( }), protocol_version: u32::from(CURRENT_PROTOCOL_VERSION.value()), model_proxy, + model_adapter: model_adapter.clone(), }); store.nodes().register_node(request).await }) diff --git a/apps/ora-controller/src/session.rs b/apps/ora-controller/src/session.rs index 216a96d32..24a21ce35 100644 --- a/apps/ora-controller/src/session.rs +++ b/apps/ora-controller/src/session.rs @@ -118,7 +118,12 @@ pub(crate) trait SessionObserver: Send + Sync { /// The handshake completed with this Node incarnation; the session is live from here on. fn established(&self, node: &NodeRuntimeIdentity); /// Records the negotiated capabilities before publishing the connected identity. - fn capabilities(&self, _capabilities: &[NodeCapability]) {} + fn capabilities( + &self, + _capabilities: &[NodeCapability], + _model_adapter: Option<&ModelAdapterCapability>, + ) { + } /// The Node still answers `Unknown` for an execution after this connection retransmitted its /// original command once. It may be a retained uncertain attempt or a command the Node has not /// admitted yet; the owner decides how long that may last. @@ -330,8 +335,12 @@ async fn coordinate, + model_adapter: Option, commands: mpsc::Sender, grants: mpsc::Sender, ) -> Self { @@ -42,6 +43,7 @@ impl Control { store, identity, capabilities, + model_adapter, receive, Workers { commands, grants }, output, @@ -144,6 +146,7 @@ async fn reconcile( store: S, identity: NodeRuntimeIdentity, capabilities: Vec, + model_adapter: Option, mut input: mpsc::Receiver, workers: Workers, output: mpsc::Sender, @@ -216,9 +219,17 @@ async fn reconcile( .original_agent_dispatch(&identity, &status.operation_id, &status.execution_id) .await? { + // A configured session retransmits only to an incarnation that still admits it. if agent_capable && (agent.payload.spec.model_binding_id.is_none() || capabilities.contains(&NodeCapability::ModelProxy)) + && agent.payload.spec.agent_configuration.as_ref().is_none_or( + |configuration| { + model_adapter + .as_ref() + .is_some_and(|adapter| adapter.admits(configuration)) + }, + ) { store.dispatch_agent(agent).await? } else { diff --git a/apps/ora-controller/tests/cloud.rs b/apps/ora-controller/tests/cloud.rs index 5e99895b8..51910f43f 100644 --- a/apps/ora-controller/tests/cloud.rs +++ b/apps/ora-controller/tests/cloud.rs @@ -110,7 +110,7 @@ impl Node { self.hellos.fetch_add(1, Ordering::SeqCst); Some(NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, - payload: HelloAccepted { thread_previews: false, + payload: HelloAccepted { model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: vec![NodeCapability::RepositoryClone, NodeCapability::RuntimeControl], diff --git a/apps/ora-controller/tests/session.rs b/apps/ora-controller/tests/session.rs index 36616742f..359cc0001 100644 --- a/apps/ora-controller/tests/session.rs +++ b/apps/ora-controller/tests/session.rs @@ -56,6 +56,7 @@ fn mismatched_node_or_missing_clone_capability_rejects_before_dispatch() { &NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { + model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: NodeRuntimeIdentity { @@ -119,7 +120,7 @@ fn uncertain_execution_retransmits_at_most_once_per_connection() { let (mut stream, _) = listener.accept().await.unwrap(); assert!(matches!(read_controller_message(&mut stream).await.unwrap(), Some(ControllerToNodeMessage::Hello(_)))); let identity = NodeRuntimeIdentity { node_id: endpoint.node_id.clone(), incarnation_id: NodeIncarnationId::new("current") }; - write_node_message(&mut stream, &NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: vec![NodeCapability::RepositoryClone] } })).await.unwrap(); + write_node_message(&mut stream, &NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: vec![NodeCapability::RepositoryClone] } })).await.unwrap(); let mut retries = 0; let mut queries = 0; while queries < 4 { @@ -167,7 +168,7 @@ fn idle_session_sends_one_heartbeat_per_query_tick() { let (mut stream, _) = listener.accept().await.unwrap(); assert!(matches!(read_controller_message(&mut stream).await.unwrap(), Some(ControllerToNodeMessage::Hello(_)))); let identity = NodeRuntimeIdentity { node_id: endpoint.node_id.clone(), incarnation_id: NodeIncarnationId::new("current") }; - write_node_message(&mut stream, &NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: vec![NodeCapability::RepositoryClone] } })).await.unwrap(); + write_node_message(&mut stream, &NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: vec![NodeCapability::RepositoryClone] } })).await.unwrap(); let expected = ControllerToNodeMessage::Heartbeat(ControllerHeartbeatMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: ControllerHeartbeat { controller_id: ControllerId::new("owner") } }); for _ in 0..5 { let message = timeout(Duration::from_millis(/*millis*/ 500), read_controller_message(&mut stream)).await.unwrap().unwrap().unwrap(); @@ -307,6 +308,7 @@ fn controller_closes_with_the_reason_code() { NodeToControllerMessage::HelloAccepted(HelloAcceptedMessage { protocol_version: CURRENT_PROTOCOL_VERSION, payload: HelloAccepted { + model_adapter: None, thread_previews: false, selected_version: CURRENT_PROTOCOL_VERSION, node: NodeRuntimeIdentity { diff --git a/apps/ora-controller/tests/support/workspace_cloud.rs b/apps/ora-controller/tests/support/workspace_cloud.rs index 9f94956c9..188090f8a 100644 --- a/apps/ora-controller/tests/support/workspace_cloud.rs +++ b/apps/ora-controller/tests/support/workspace_cloud.rs @@ -1037,6 +1037,7 @@ impl NodeReportService for WorkspaceCloud { version: 1, idle_admission_epoch: None, model_proxy: message.model_proxy, + model_adapter: message.model_adapter, }; state.nodes.push(record.clone()); record diff --git a/apps/ora-controller/tests/support/workspace_cloud/agents.rs b/apps/ora-controller/tests/support/workspace_cloud/agents.rs index 0a67e9896..094b33725 100644 --- a/apps/ora-controller/tests/support/workspace_cloud/agents.rs +++ b/apps/ora-controller/tests/support/workspace_cloud/agents.rs @@ -46,6 +46,27 @@ impl WorkspaceCloud { run: &str, prior: Option, binding: &str, + ) { + self.queue_session_input(run, prior, binding, /*configuration*/ None); + } + + /// Freezes a model binding together with the adapter configuration Cloud admitted. + pub fn queue_configured_agent( + &self, + run: &str, + binding: &str, + configuration: proto::AgentModelConfiguration, + ) { + self.queue_session_input(run, /*prior*/ None, binding, Some(configuration)); + } + + /// Queues one session work item and wakes the Controller. + fn queue_session_input( + &self, + run: &str, + prior: Option, + binding: &str, + configuration: Option, ) { let mut state = self.lock(); let sandbox = state.sandboxes.last().unwrap(); @@ -60,6 +81,7 @@ impl WorkspaceCloud { input: Some(proto::ExecutionInput { spec: Some(proto::execution_input::Spec::AgentSession( proto::AgentSessionSpec { + agent_configuration: configuration, agent_plugin_id: "official/ora-space.echo".into(), agent_plugin_version: "1.2.3".into(), checkout_execution_id: state.clones[0].execution_id.clone(), @@ -115,6 +137,15 @@ impl WorkspaceCloud { }, ] } + /// The adapter facts each registered Node record carries, in registration order. + pub fn registered_model_adapters(&self) -> Vec> { + self.lock() + .nodes + .iter() + .map(|node| node.model_adapter.clone()) + .collect() + } + /// Reads registered session facts for assertions and subsequent command input. pub fn agent_records(&self) -> Vec { self.lock() diff --git a/apps/ora-controller/tests/workspaces.rs b/apps/ora-controller/tests/workspaces.rs index ece103765..5e1d4309e 100644 --- a/apps/ora-controller/tests/workspaces.rs +++ b/apps/ora-controller/tests/workspaces.rs @@ -247,6 +247,7 @@ impl Node { selected_version: CURRENT_PROTOCOL_VERSION, node: identity.clone(), capabilities: plugins::capabilities(self), + model_adapter: self.agents.model_adapter.lock().unwrap().clone(), }, })) }, ControllerToNodeMessage::BindRuntime(binding) => Some(NodeToControllerMessage::RuntimeControlState(RuntimeControlState { binding, unfinished_execution_ids:vec![] })), diff --git a/apps/ora-controller/tests/workspaces/agents.rs b/apps/ora-controller/tests/workspaces/agents.rs index 79b205af6..f7542130c 100644 --- a/apps/ora-controller/tests/workspaces/agents.rs +++ b/apps/ora-controller/tests/workspaces/agents.rs @@ -17,6 +17,7 @@ pub(super) struct AgentNode { data: Arc>, pub(super) enabled: Arc, pub(super) model_proxy: Arc, + pub(super) model_adapter: Arc>>, pub(super) previews_enabled: Arc, pub(super) previews_negotiated: Arc, pub(super) drop_reply: Arc, @@ -253,6 +254,134 @@ fn model_binding_is_preserved_through_controller_dispatch() { ); }); } +/// The frozen adapter input Cloud admitted for `official/ora-space.echo` 1.2.3. +fn frozen_configuration() -> proto::AgentModelConfiguration { + proto::AgentModelConfiguration { + plugin_id: "official/ora-space.echo".into(), + plugin_version: "1.2.3".into(), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: "a".repeat(64), + schema_version: 2, + declaration_fingerprint: "b".repeat(64), + adapter_version: 1, + configuration_revision: 7, + settings_json: r#"{"effort":12345678901234567890}"#.into(), + protocol: "anthropic-messages".into(), + model: Some(proto::AgentBoundModel { + id: "team/model".into(), + name: "Team model".into(), + context_window: 200_000, + max_tokens: 8192, + }), + } +} + +/// The Node adapter facts that admit [`frozen_configuration`]. +fn matching_adapter() -> ModelAdapterCapability { + ModelAdapterCapability { + contract_version: MODEL_ADAPTER_CONTRACT_VERSION, + target: "x86_64-unknown-linux-gnu".into(), + protocols: vec![ModelProtocol::AnthropicMessages], + } +} + +/// A model-proxy Node whose adapter facts do not admit the frozen configuration leaves it queued: +/// another target, another protocol and no adapter at all are each a mismatch. +#[test] +fn configured_session_waits_for_a_matching_model_adapter() { + for adapter in [ + None, + Some(ModelAdapterCapability { + target: "aarch64-unknown-linux-gnu".into(), + ..matching_adapter() + }), + Some(ModelAdapterCapability { + protocols: vec![ModelProtocol::OpenaiCompletions], + ..matching_adapter() + }), + ] { + scenario("main", move |world| async move { + world.node.agents.model_proxy.store(true, Ordering::SeqCst); + *world.node.agents.model_adapter.lock().unwrap() = adapter; + workspace(&world).await; + world + .cloud + .queue_configured_agent("model-run", "binding-1", frozen_configuration()); + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!(world.cloud.agent_records(), Vec::new()); + assert_eq!(world.node.agents.data.lock().unwrap().starts.len(), 0); + }); + } +} + +/// The frozen configuration reaches the Node unchanged, numeric lexemes included, beside its +/// binding; registration reports the adapter facts of the same handshake to Cloud. +#[test] +fn frozen_configuration_is_preserved_through_controller_dispatch() { + scenario("main", |world| async move { + world.node.agents.model_proxy.store(true, Ordering::SeqCst); + *world.node.agents.model_adapter.lock().unwrap() = Some(matching_adapter()); + workspace(&world).await; + world + .cloud + .queue_configured_agent("model-run", "binding-1", frozen_configuration()); + world + .timeline + .until(|events| { + events + .iter() + .any(|event| matches!(event, Event::AgentStarted { .. })) + }) + .await; + let spec = world + .node + .agents + .data + .lock() + .unwrap() + .starts + .values() + .next() + .unwrap() + .payload + .spec + .clone(); + assert_eq!( + spec.model_binding_id, + Some(ModelBindingId::new("binding-1")) + ); + assert_eq!( + spec.agent_configuration, + Some(FrozenAgentConfiguration { + plugin_id: PluginId::new("official/ora-space.echo"), + plugin_version: PluginVersion::new("1.2.3"), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: Sha256Digest::new("a".repeat(64)), + schema_version: 2, + declaration_fingerprint: Sha256Digest::new("b".repeat(64)), + adapter_version: 1, + configuration_revision: 7, + settings_json: r#"{"effort":12345678901234567890}"#.into(), + protocol: ModelProtocol::AnthropicMessages, + model: BoundModel { + id: "team/model".into(), + name: "Team model".into(), + context_window: 200_000, + max_tokens: 8192, + }, + }) + ); + assert_eq!( + world.cloud.registered_model_adapters(), + vec![Some(proto::ModelAdapterCapabilities { + contract_version: 1, + target: "x86_64-unknown-linux-gnu".into(), + protocols: vec!["anthropic-messages".into()], + })] + ); + }); +} + /// Waits for an exact ACK, never treating a Cloud query or log as receipt. async fn acked(world: &World, execution: &ExecutionId, sequence: u64) { world From 259449c7fffff143be673b67dbd306d49971d2d5 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 12:36:54 +0000 Subject: [PATCH 04/18] feat(node): hand model grants to plugin-owned adapters A configured session no longer gets Node-written OpenCode configuration. The Node checks that the grant serves exactly the frozen protocol and model, gives the plugin a private home and the public CA, and passes the token only in the agent/start model context; the runtime refuses ACP until the adapter confirms the configuration, and every failure path revokes the grant. The echo fixture acts as a minimal adapter, and the gateway fixture is shared with the session integration tests. --- apps/ora-node/fixtures/echo-agent/agent.rs | 47 ++- apps/ora-node/fixtures/echo-agent/main.rs | 3 +- apps/ora-node/src/model_proxy.rs | 77 +++- apps/ora-node/src/model_proxy/adapter.rs | 70 ++++ apps/ora-node/src/model_proxy/opencode.rs | 42 +- apps/ora-node/src/model_proxy/test_gateway.rs | 211 ++++++++++ apps/ora-node/src/model_proxy/tests.rs | 364 ++++++++---------- apps/ora-node/src/ora_git/tests.rs | 1 + apps/ora-node/src/service.rs | 2 + apps/ora-node/src/service/agents.rs | 8 + apps/ora-node/src/service/session.rs | 1 + apps/ora-node/src/service/worker.rs | 2 + apps/ora-node/src/session.rs | 16 + apps/ora-node/src/session/driver.rs | 15 +- apps/ora-node/src/session/host.rs | 7 + apps/ora-node/src/session/model/tests.rs | 3 + apps/ora-node/src/session/workload/tests.rs | 1 + apps/ora-node/tests/agent_session.rs | 102 +++++ apps/ora-node/tests/agent_session/support.rs | 72 +++- apps/ora-node/tests/session_ledger.rs | 1 + .../standalone/agent_sessions/support.rs | 1 + 21 files changed, 786 insertions(+), 260 deletions(-) create mode 100644 apps/ora-node/src/model_proxy/adapter.rs create mode 100644 apps/ora-node/src/model_proxy/test_gateway.rs diff --git a/apps/ora-node/fixtures/echo-agent/agent.rs b/apps/ora-node/fixtures/echo-agent/agent.rs index b8e402a3f..4d4bc7f5d 100644 --- a/apps/ora-node/fixtures/echo-agent/agent.rs +++ b/apps/ora-node/fixtures/echo-agent/agent.rs @@ -1,4 +1,8 @@ //! Plugin protocol shell and the minimal ACP agent inside it. +//! +//! Given a model context, the fixture acts as a minimal adapter: it confirms the exact frozen +//! configuration it received, unless its Settings ask it to refuse (`{"refuse": true}`), so a +//! test can show that an unconfirmed adapter never reaches ACP. use agent_client_protocol_schema::v1::{ AGENT_METHOD_NAMES, AgentCapabilities, CLIENT_METHOD_NAMES, CancelNotification, @@ -9,9 +13,10 @@ use agent_client_protocol_schema::v1::{ }; use ora_plugin_protocol::{ AGENT_ACP_METHOD, AGENT_LIST_MODELS_METHOD, AGENT_START_METHOD, AGENT_STOP_METHOD, - AgentListModelsResult, AgentStartResult, CHILDPROCESS_EXIT_METHOD, CHILDPROCESS_SPAWN_METHOD, - JSON_RPC_VERSION, METHOD_NOT_FOUND_CODE, PluginRegistrationParams, REGISTER_METHOD, - SHUTDOWN_METHOD, read_message, write_message, + AgentListModelsResult, AgentModelApplied, AgentModelContext, AgentStartContext, + AgentStartResult, CHILDPROCESS_EXIT_METHOD, CHILDPROCESS_SPAWN_METHOD, JSON_RPC_VERSION, + METHOD_NOT_FOUND_CODE, PluginRegistrationParams, REGISTER_METHOD, SHUTDOWN_METHOD, + read_message, write_message, }; use serde::Serialize; use serde_json::{Value, json}; @@ -41,6 +46,8 @@ struct EchoAgent { held: Option, /// A `[commit]` prompt, answered when the host-spawned commit exits. committing: Option, + /// The model context the host started this plugin with, if any. + model: Option, } /// Runs until the host sends `ora/shutdown` or closes stdin. @@ -72,7 +79,7 @@ pub(super) async fn run() -> Result<(), Box> { (Some(CHILDPROCESS_EXIT_METHOD), None) => agent.commit_finished(&mut output).await?, (Some(method), Some(id)) => { let result = match method { - AGENT_START_METHOD => serde_json::to_value(AgentStartResult::acp_v1())?, + AGENT_START_METHOD => serde_json::to_value(agent.start(params)?)?, AGENT_STOP_METHOD => json!({}), AGENT_LIST_MODELS_METHOD => { serde_json::to_value(AgentListModelsResult { models: Vec::new() })? @@ -98,6 +105,24 @@ pub(super) async fn run() -> Result<(), Box> { } impl EchoAgent { + /// Confirms a supplied model context exactly, the way a real adapter does once it applied it. + fn start(&mut self, params: Value) -> Result> { + let start: AgentStartContext = serde_json::from_value(params)?; + let mut result = AgentStartResult::acp_v1(); + if let Some(context) = start.model_context { + let settings: Value = serde_json::from_str(&context.configuration.settings_json)?; + if settings.get("refuse") != Some(&Value::Bool(true)) { + result.model_applied = Some(AgentModelApplied { + adapter_version: context.configuration.adapter_version, + declaration_fingerprint: context.configuration.declaration_fingerprint.clone(), + configuration_revision: context.configuration.configuration_revision, + }); + } + self.model = Some(context); + } + Ok(result) + } + /// Serves one ACP frame from the host. async fn acp_frame( &mut self, @@ -240,7 +265,19 @@ impl EchoAgent { message(output, &session_id, &answer).await?; return respond(output, id, &PromptResponse::new(StopReason::EndTurn)).await; } - let answer = if text.contains("[env]") { + let answer = if text.contains("[model]") { + // Reports what the adapter received, and whether the token also leaked into the + // environment every process the plugin spawns would inherit. + match &self.model { + Some(model) => format!( + "model={} proxy={} token_in_environment={}", + model.configuration.model.id, + model.proxy_base_url, + std::env::vars().any(|(_, value)| value.contains(&model.token)), + ), + None => "model=none".to_string(), + } + } else if text.contains("[env]") { let cwd = std::env::current_dir().unwrap_or_default(); let home = std::env::var("HOME").unwrap_or_default(); format!("cwd={} home={home}", cwd.display()) diff --git a/apps/ora-node/fixtures/echo-agent/main.rs b/apps/ora-node/fixtures/echo-agent/main.rs index fe5c1ddaf..bd187e13e 100644 --- a/apps/ora-node/fixtures/echo-agent/main.rs +++ b/apps/ora-node/fixtures/echo-agent/main.rs @@ -10,7 +10,8 @@ //! `ora/childprocess/spawn`, and completes when that process exits; //! - `[commit-direct]` runs the same commit as its own child process, inheriting its environment; //! - `[env]` answers with its working directory and `HOME`, showing where and as what it runs; -//! - `[large]` answers with a message larger than one Thread record may be. +//! - `[large]` answers with a message larger than one Thread record may be; +//! - `[model]` answers with the model context it was started with, if any. //! //! On start it appends its process id to `echo-agent.pids` in its working directory, the package //! root, so a test can check that every plugin process it started has exited. diff --git a/apps/ora-node/src/model_proxy.rs b/apps/ora-node/src/model_proxy.rs index 23f4e84ab..5635e5cb2 100644 --- a/apps/ora-node/src/model_proxy.rs +++ b/apps/ora-node/src/model_proxy.rs @@ -1,14 +1,22 @@ //! Short-lived platform model grants owned by one live agent session, never by its ledger. +mod adapter; mod opencode; #[cfg(test)] +mod test_gateway; +#[cfg(test)] pub(crate) mod tests; use chrono::{DateTime, FixedOffset}; -use ora_node_protocol::{ExecutionId, ModelBindingId}; +use ora_node_protocol::{ExecutionId, FrozenAgentConfiguration, ModelBindingId, ModelProtocol}; +use ora_plugin_protocol::AgentModelContext; use reqwest::{Certificate, Client, Identity, Url}; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, path::PathBuf, time::Duration}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + time::Duration, +}; use tokio::{sync::watch, task::JoinHandle}; /// Dedicated model-access client TLS supplied by deployment, separate from Node server TLS. @@ -42,13 +50,6 @@ struct ModelClient { public_ca: Vec, } -#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -enum Protocol { - OpenaiCompletions, - AnthropicMessages, -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Model { @@ -64,7 +65,7 @@ struct Grant { grant_id: String, token: String, expires_at: DateTime, - protocol: Protocol, + protocol: ModelProtocol, proxy_base_url: String, model: Model, } @@ -181,8 +182,8 @@ impl ModelClient { || data.fragment().is_some() || data.path() != match grant.protocol { - Protocol::OpenaiCompletions => "/runtime/openai/v1", - Protocol::AnthropicMessages => "/runtime/anthropic/v1", + ModelProtocol::OpenaiCompletions => "/runtime/openai/v1", + ModelProtocol::AnthropicMessages => "/runtime/anthropic/v1", } || grant.token.is_empty() || grant.model.id.is_empty() @@ -238,9 +239,20 @@ fn valid_grant_id(id: &str) -> bool { (1..=128).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') } -/// A renewable grant and isolated OpenCode state whose owner is exactly one live conversation. +/// Who turns the grant into CLI configuration for one session. +#[derive(Clone, Copy)] +pub(super) enum ModelRoute<'a> { + /// An undeclared OpenCode package: the Node still writes its configuration. + LegacyOpenCode, + /// A declared package whose adapter applies the frozen configuration itself. + Adapter(&'a FrozenAgentConfiguration), +} + +/// A renewable grant and isolated CLI state whose owner is exactly one live conversation. pub(super) struct ModelAccess { pub(super) environment: BTreeMap, + /// The adapter's startup input; the only place the token exists on the adapter route. + pub(super) context: Option, _home: tempfile::TempDir, client: ModelClient, grant: String, @@ -283,11 +295,20 @@ impl ModelAccess { binding: &ModelBindingId, execution: &ExecutionId, node_home: &std::path::Path, + route: ModelRoute<'_>, ) -> Result { let client = ModelClient::open(config)?; let grant = client.grant(binding, execution).await?; - let setup = opencode::environment(&grant, &client.config.ca_cert, node_home); - let (environment, home) = match setup { + let ca = &client.config.ca_cert; + let setup = match route { + ModelRoute::LegacyOpenCode => opencode::environment(&grant, ca, node_home) + .map(|(environment, home)| (environment, home, None)), + ModelRoute::Adapter(configuration) => { + adapter::start(&grant, configuration, ca, node_home) + .map(|(environment, home, context)| (environment, home, Some(context))) + } + }; + let (environment, home, context) = match setup { Ok(setup) => setup, Err(error) => { client.revoke(&grant.grant_id, execution).await; @@ -320,6 +341,7 @@ impl ModelAccess { }); Ok(Self { environment, + context, _home: home, client, grant: grant.grant_id, @@ -341,6 +363,31 @@ impl ModelAccess { } } +/// Creates one session's private CLI home under the Node's runtime directory, never in a checkout. +fn isolated_home(node_home: &Path, prefix: &str) -> Result { + let runtime_root = node_home.join("model-runtime"); + ora_utils::path::create_directories_without_symlinks(&runtime_root) + .map_err(|_| ModelAccessError("model_runtime_state_unavailable"))?; + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(runtime_root) + .map_err(|_| ModelAccessError("model_runtime_state_unavailable")) +} + +/// Points every per-user base directory at `home`, so no CLI reads the management host's own +/// configuration, credentials or caches, and trusts the platform proxy's public CA. +fn home_variables(home: &Path, ca: &Path) -> BTreeMap { + let path = |path: PathBuf| path.to_string_lossy().into_owned(); + BTreeMap::from([ + ("NODE_EXTRA_CA_CERTS".to_string(), path(ca.to_path_buf())), + ("HOME".to_string(), path(home.to_path_buf())), + ("XDG_CONFIG_HOME".to_string(), path(home.join("config"))), + ("XDG_CACHE_HOME".to_string(), path(home.join("cache"))), + ("XDG_DATA_HOME".to_string(), path(home.join("data"))), + ("XDG_STATE_HOME".to_string(), path(home.join("state"))), + ]) +} + /// Reads only a bounded safe fault code; all other upstream error content is discarded. async fn denial(mut response: reqwest::Response, fallback: &'static str) -> ModelAccessError { let ending_status = response.status() == reqwest::StatusCode::FORBIDDEN; diff --git a/apps/ora-node/src/model_proxy/adapter.rs b/apps/ora-node/src/model_proxy/adapter.rs new file mode 100644 index 000000000..4f8ec05a0 --- /dev/null +++ b/apps/ora-node/src/model_proxy/adapter.rs @@ -0,0 +1,70 @@ +//! The plugin-owned adapter route: the Node hands over a grant, never CLI configuration. +//! +//! The Node keeps what a plugin must not choose (the binding, the grant, the proxy endpoint, the +//! private home and the trusted CA) and the adapter alone turns that into its CLI's native +//! configuration. The token therefore travels only inside the live startup context; it is never +//! placed in the plugin's environment, where every process it spawns would inherit it. + +use super::{Grant, ModelAccessError}; +use ora_node_protocol::{FrozenAgentConfiguration, ModelProtocol}; +use ora_plugin_protocol::{ + AgentBoundModel, AgentModelConfiguration, AgentModelContext, AgentModelProtocol, +}; +use std::{collections::BTreeMap, path::Path}; + +/// Proves the grant serves exactly the frozen configuration, then builds the adapter's input. +/// +/// A grant for another protocol or model means the binding and the frozen configuration +/// disagree; starting anyway would let the adapter send requests the configuration never +/// admitted, so the session fails before any plugin process exists. +pub(super) fn start( + grant: &Grant, + configuration: &FrozenAgentConfiguration, + ca: &Path, + node_home: &Path, +) -> Result< + ( + BTreeMap, + tempfile::TempDir, + AgentModelContext, + ), + ModelAccessError, +> { + let model = &configuration.model; + if grant.protocol != configuration.protocol + || grant.model.id != model.id + || grant.model.name != model.name + || grant.model.context_window != model.context_window + || grant.model.max_tokens != model.max_tokens + { + return Err(ModelAccessError("model_grant_mismatch")); + } + let home = super::isolated_home(node_home, "agent-")?; + let environment = super::home_variables(home.path(), ca); + let context = AgentModelContext { + configuration: AgentModelConfiguration { + plugin_id: configuration.plugin_id.as_str().to_string(), + plugin_version: configuration.plugin_version.as_str().to_string(), + target: configuration.target.clone(), + package_sha256: configuration.package_sha256.as_str().to_string(), + schema_version: configuration.schema_version, + declaration_fingerprint: configuration.declaration_fingerprint.as_str().to_string(), + adapter_version: configuration.adapter_version, + configuration_revision: configuration.configuration_revision, + settings_json: configuration.settings_json.clone(), + protocol: match configuration.protocol { + ModelProtocol::OpenaiCompletions => AgentModelProtocol::OpenaiCompletions, + ModelProtocol::AnthropicMessages => AgentModelProtocol::AnthropicMessages, + }, + model: AgentBoundModel { + id: model.id.clone(), + name: model.name.clone(), + context_window: model.context_window, + max_tokens: model.max_tokens, + }, + }, + proxy_base_url: grant.proxy_base_url.clone(), + token: grant.token.clone(), + }; + Ok((environment, home, context)) +} diff --git a/apps/ora-node/src/model_proxy/opencode.rs b/apps/ora-node/src/model_proxy/opencode.rs index 7d44856f1..f85b44972 100644 --- a/apps/ora-node/src/model_proxy/opencode.rs +++ b/apps/ora-node/src/model_proxy/opencode.rs @@ -1,6 +1,7 @@ //! OpenCode config uses environment references so serialization cannot persist the runtime token. -use super::{Grant, ModelAccessError, Protocol}; +use super::{Grant, ModelAccessError}; +use ora_node_protocol::ModelProtocol; use serde_json::json; use std::{collections::BTreeMap, path::Path}; @@ -10,13 +11,7 @@ pub(super) fn environment( ca: &Path, node_home: &Path, ) -> Result<(BTreeMap, tempfile::TempDir), ModelAccessError> { - let runtime_root = node_home.join("model-runtime"); - ora_utils::path::create_directories_without_symlinks(&runtime_root) - .map_err(|_| ModelAccessError("model_runtime_state_unavailable"))?; - let home = tempfile::Builder::new() - .prefix("opencode-") - .tempdir_in(runtime_root) - .map_err(|_| ModelAccessError("model_runtime_state_unavailable"))?; + let home = super::isolated_home(node_home, "opencode-")?; let model_id = &grant.model.id; let config = json!({ "$schema": "https://opencode.ai/config.json", @@ -25,8 +20,8 @@ pub(super) fn environment( "enabled_providers": ["ora-model"], "provider": {"ora-model": { "npm": match grant.protocol { - Protocol::OpenaiCompletions => "@ai-sdk/openai-compatible", - Protocol::AnthropicMessages => "@ai-sdk/anthropic", + ModelProtocol::OpenaiCompletions => "@ai-sdk/openai-compatible", + ModelProtocol::AnthropicMessages => "@ai-sdk/anthropic", }, "name": "Ora model gateway", "options": {"baseURL": grant.proxy_base_url, "apiKey": "{env:ORA_MODEL_ACCESS_TOKEN}"}, @@ -36,33 +31,10 @@ pub(super) fn environment( }}, }}, }); - let mut variables = BTreeMap::from([ + let mut variables = super::home_variables(home.path(), ca); + variables.extend([ ("OPENCODE_CONFIG_CONTENT".to_string(), config.to_string()), ("ORA_MODEL_ACCESS_TOKEN".to_string(), grant.token.clone()), - ( - "NODE_EXTRA_CA_CERTS".to_string(), - ca.to_string_lossy().into_owned(), - ), - ( - "HOME".to_string(), - home.path().to_string_lossy().into_owned(), - ), - ( - "XDG_CONFIG_HOME".to_string(), - home.path().join("config").to_string_lossy().into_owned(), - ), - ( - "XDG_CACHE_HOME".to_string(), - home.path().join("cache").to_string_lossy().into_owned(), - ), - ( - "XDG_DATA_HOME".to_string(), - home.path().join("data").to_string_lossy().into_owned(), - ), - ( - "XDG_STATE_HOME".to_string(), - home.path().join("state").to_string_lossy().into_owned(), - ), ( "OPENCODE_DISABLE_AUTOUPDATE".to_string(), "true".to_string(), diff --git a/apps/ora-node/src/model_proxy/test_gateway.rs b/apps/ora-node/src/model_proxy/test_gateway.rs new file mode 100644 index 000000000..f96b1bbb9 --- /dev/null +++ b/apps/ora-node/src/model_proxy/test_gateway.rs @@ -0,0 +1,211 @@ +//! A model-gateway fixture serving the real grant HTTP contract over mTLS. +//! +//! Shared by the crate's unit tests and the session integration tests, which include this file +//! by path; the including module provides `ModelProxyConfig` as `super::ModelProxyConfig`. + +#![allow(clippy::unwrap_used)] + +use super::ModelProxyConfig; +use serde_json::{Value, json}; +use std::{ + io::{Read, Write}, + net::TcpListener, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +/// Generates narrowly purposed TLS material and serves the real grant HTTP contract over mTLS. +pub(crate) struct Gateway { + pub(crate) config: ModelProxyConfig, + pub(crate) requests: Arc>>, + stop: Arc, + worker: Option>, + pub(crate) behavior: Arc, +} + +#[derive(Default)] +pub(crate) struct GatewayBehavior { + pub(crate) foreign_endpoint: AtomicBool, + pub(crate) deny_renewal: AtomicBool, + /// Serves the Anthropic Messages protocol instead of OpenAI Completions. + pub(crate) anthropic: AtomicBool, + pub(crate) ending_initially: AtomicBool, + pub(crate) ending_at_renewal: AtomicBool, +} + +impl Gateway { + /// Test tokens are synthetic and never pass through the durable execution protocol. + pub(crate) fn new(root: &std::path::Path) -> Self { + ora_logging::initialize_test_clock(); + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + let ca = + rcgen::CertifiedIssuer::self_signed(ca_params, rcgen::KeyPair::generate().unwrap()) + .unwrap(); + let key = rcgen::KeyPair::generate().unwrap(); + let mut params = rcgen::CertificateParams::new(vec!["localhost".to_owned()]).unwrap(); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let server = params.signed_by(&key, &ca).unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(ca.der().clone()).unwrap(); + let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .unwrap(); + let server_config = Arc::new( + rustls::ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert( + vec![server.der().clone()], + rustls::pki_types::PrivatePkcs8KeyDer::from(key.serialize_der()).into(), + ) + .unwrap(), + ); + let client_key = rcgen::KeyPair::generate().unwrap(); + let mut client_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + let client = client_params.signed_by(&client_key, &ca).unwrap(); + let ca_cert = root.join("model-ca.pem"); + let client_cert = root.join("model-client.pem"); + let client_key_path = root.join("model-client-key.pem"); + std::fs::write(&ca_cert, ca.pem()).unwrap(); + std::fs::write(&client_cert, client.pem()).unwrap(); + std::fs::write(&client_key_path, client_key.serialize_pem()).unwrap(); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(Mutex::new(Vec::new())); + let observed = requests.clone(); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = stop.clone(); + let behavior = Arc::new(GatewayBehavior::default()); + let serving = behavior.clone(); + let worker = std::thread::spawn(move || { + while !stopping.load(Ordering::SeqCst) { + match listener.accept() { + Ok((socket, _)) => { + socket + .set_read_timeout(Some(Duration::from_secs(/*secs*/ 2))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(/*secs*/ 2))) + .unwrap(); + let connection = + rustls::ServerConnection::new(server_config.clone()).unwrap(); + let mut stream = rustls::StreamOwned::new(connection, socket); + let _ = serve(&mut stream, &observed, port, &serving); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(/*millis*/ 5)) + } + Err(_) => break, + } + } + }); + Self { + config: ModelProxyConfig { + gateway_url: format!("https://localhost:{port}"), + ca_cert, + client_cert, + client_key: client_key_path, + }, + requests, + stop, + worker: Some(worker), + behavior, + } + } +} + +impl Drop for Gateway { + /// Fixture timeouts ensure a failed assertion cannot leave a listener running. + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + self.worker.take().unwrap().join().unwrap(); + } +} + +/// Captures only authenticated scoped grant requests and returns short renewable fixture grants. +fn serve( + stream: &mut impl ReadWrite, + observed: &Mutex>, + port: u16, + behavior: &GatewayBehavior, +) -> std::io::Result<()> { + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") && header.len() < 8192 { + let mut byte = [0]; + stream.read_exact(&mut byte)?; + header.extend(byte); + } + let header = String::from_utf8(header).unwrap(); + let request = header.lines().next().unwrap().to_string(); + let length: usize = header + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .unwrap() + .parse() + .unwrap(); + let mut body = vec![0; length]; + stream.read_exact(&mut body)?; + let body: Value = serde_json::from_slice(&body).unwrap(); + observed.lock().unwrap().push((request.clone(), body)); + if request.starts_with("POST /internal/v1/model-grants ") + && behavior.ending_initially.load(Ordering::SeqCst) + || request.contains("/renew ") && behavior.ending_at_renewal.load(Ordering::SeqCst) + { + let body = + json!({"code":"model_session_ending","params":{},"requestId":"fixture"}).to_string(); + write!( + stream, + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + return stream.flush(); + } + if request.starts_with("POST ") + && request.contains("/renew ") + && behavior.deny_renewal.load(Ordering::SeqCst) + { + stream.write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + )?; + return stream.flush(); + } + let (protocol, path) = if behavior.anthropic.load(Ordering::SeqCst) { + ("anthropic-messages", "anthropic") + } else { + ("openai-completions", "openai") + }; + let data_url = if behavior.foreign_endpoint.load(Ordering::SeqCst) { + format!("https://untrusted.invalid/runtime/{path}/v1") + } else { + format!("https://localhost:{port}/runtime/{path}/v1") + }; + let response = if request.starts_with("POST /internal/v1/model-grants ") { + json!({"grantId":"grant-1", "token":"synthetic-temporary-token", "expiresAt": chrono::Local::now().fixed_offset() + chrono::Duration::seconds(2), + "protocol":protocol, "proxyBaseUrl":data_url, + "model":{"id":"team/model", "name":"Fixture", "contextWindow":32768,"maxTokens":1024}}) + } else if request.starts_with("POST ") { + json!({"expiresAt":chrono::Local::now().fixed_offset() + chrono::Duration::minutes(15)}) + } else { + json!({}) + }.to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", + response.len() + )?; + stream.flush() +} + +/// Allows the synchronous fixture to operate on a TLS stream without exposing transport internals. +trait ReadWrite: Read + Write {} +impl ReadWrite for T {} diff --git a/apps/ora-node/src/model_proxy/tests.rs b/apps/ora-node/src/model_proxy/tests.rs index 566971635..72db9dba4 100644 --- a/apps/ora-node/src/model_proxy/tests.rs +++ b/apps/ora-node/src/model_proxy/tests.rs @@ -3,200 +3,9 @@ use super::*; use pretty_assertions::assert_eq; use serde_json::{Value, json}; -use std::{ - io::{Read, Write}, - net::TcpListener, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, -}; +use std::sync::atomic::Ordering; -/// Generates narrowly purposed TLS material and serves the real grant HTTP contract over mTLS. -pub(crate) struct Gateway { - pub(crate) config: ModelProxyConfig, - pub(crate) requests: Arc>>, - stop: Arc, - worker: Option>, - pub(crate) behavior: Arc, -} - -#[derive(Default)] -pub(crate) struct GatewayBehavior { - foreign_endpoint: AtomicBool, - deny_renewal: AtomicBool, - pub(crate) ending_initially: AtomicBool, - pub(crate) ending_at_renewal: AtomicBool, -} - -impl Gateway { - /// Test tokens are synthetic and never pass through the durable execution protocol. - pub(crate) fn new(root: &std::path::Path) -> Self { - ora_logging::initialize_test_clock(); - let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); - ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; - let ca = - rcgen::CertifiedIssuer::self_signed(ca_params, rcgen::KeyPair::generate().unwrap()) - .unwrap(); - let key = rcgen::KeyPair::generate().unwrap(); - let mut params = rcgen::CertificateParams::new(vec!["localhost".to_owned()]).unwrap(); - params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; - let server = params.signed_by(&key, &ca).unwrap(); - let mut roots = rustls::RootCertStore::empty(); - roots.add(ca.der().clone()).unwrap(); - let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)) - .build() - .unwrap(); - let server_config = Arc::new( - rustls::ServerConfig::builder() - .with_client_cert_verifier(verifier) - .with_single_cert( - vec![server.der().clone()], - rustls::pki_types::PrivatePkcs8KeyDer::from(key.serialize_der()).into(), - ) - .unwrap(), - ); - let client_key = rcgen::KeyPair::generate().unwrap(); - let mut client_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); - client_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; - let client = client_params.signed_by(&client_key, &ca).unwrap(); - let ca_cert = root.join("model-ca.pem"); - let client_cert = root.join("model-client.pem"); - let client_key_path = root.join("model-client-key.pem"); - std::fs::write(&ca_cert, ca.pem()).unwrap(); - std::fs::write(&client_cert, client.pem()).unwrap(); - std::fs::write(&client_key_path, client_key.serialize_pem()).unwrap(); - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = listener.local_addr().unwrap().port(); - let requests = Arc::new(Mutex::new(Vec::new())); - let observed = requests.clone(); - let stop = Arc::new(AtomicBool::new(false)); - let stopping = stop.clone(); - let behavior = Arc::new(GatewayBehavior::default()); - let serving = behavior.clone(); - let worker = std::thread::spawn(move || { - while !stopping.load(Ordering::SeqCst) { - match listener.accept() { - Ok((socket, _)) => { - socket - .set_read_timeout(Some(Duration::from_secs(/*secs*/ 2))) - .unwrap(); - socket - .set_write_timeout(Some(Duration::from_secs(/*secs*/ 2))) - .unwrap(); - let connection = - rustls::ServerConnection::new(server_config.clone()).unwrap(); - let mut stream = rustls::StreamOwned::new(connection, socket); - let _ = serve(&mut stream, &observed, port, &serving); - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(/*millis*/ 5)) - } - Err(_) => break, - } - } - }); - Self { - config: ModelProxyConfig { - gateway_url: format!("https://localhost:{port}"), - ca_cert, - client_cert, - client_key: client_key_path, - }, - requests, - stop, - worker: Some(worker), - behavior, - } - } -} - -impl Drop for Gateway { - /// Fixture timeouts ensure a failed assertion cannot leave a listener running. - fn drop(&mut self) { - self.stop.store(true, Ordering::SeqCst); - self.worker.take().unwrap().join().unwrap(); - } -} - -/// Captures only authenticated scoped grant requests and returns short renewable fixture grants. -fn serve( - stream: &mut impl ReadWrite, - observed: &Mutex>, - port: u16, - behavior: &GatewayBehavior, -) -> std::io::Result<()> { - let mut header = Vec::new(); - while !header.ends_with(b"\r\n\r\n") && header.len() < 8192 { - let mut byte = [0]; - stream.read_exact(&mut byte)?; - header.extend(byte); - } - let header = String::from_utf8(header).unwrap(); - let request = header.lines().next().unwrap().to_string(); - let length: usize = header - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length: ") - .map(str::to_owned) - }) - .unwrap() - .parse() - .unwrap(); - let mut body = vec![0; length]; - stream.read_exact(&mut body)?; - let body: Value = serde_json::from_slice(&body).unwrap(); - observed.lock().unwrap().push((request.clone(), body)); - if request.starts_with("POST /internal/v1/model-grants ") - && behavior.ending_initially.load(Ordering::SeqCst) - || request.contains("/renew ") && behavior.ending_at_renewal.load(Ordering::SeqCst) - { - let body = - json!({"code":"model_session_ending","params":{},"requestId":"fixture"}).to_string(); - write!( - stream, - "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - )?; - return stream.flush(); - } - if request.starts_with("POST ") - && request.contains("/renew ") - && behavior.deny_renewal.load(Ordering::SeqCst) - { - stream.write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - )?; - return stream.flush(); - } - let data_url = if behavior.foreign_endpoint.load(Ordering::SeqCst) { - "https://untrusted.invalid/runtime/openai/v1".to_owned() - } else { - format!("https://localhost:{port}/runtime/openai/v1") - }; - let response = if request.starts_with("POST /internal/v1/model-grants ") { - json!({"grantId":"grant-1", "token":"synthetic-temporary-token", "expiresAt": chrono::Local::now().fixed_offset() + chrono::Duration::seconds(2), - "protocol":"openai-completions", "proxyBaseUrl":data_url, - "model":{"id":"team/model", "name":"Fixture", "contextWindow":32768,"maxTokens":1024}}) - } else if request.starts_with("POST ") { - json!({"expiresAt":chrono::Local::now().fixed_offset() + chrono::Duration::minutes(15)}) - } else { - json!({}) - }.to_string(); - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", - response.len() - )?; - stream.flush() -} - -/// Allows the synchronous fixture to operate on a TLS stream without exposing transport internals. -trait ReadWrite: Read + Write {} -impl ReadWrite for T {} +pub(crate) use super::test_gateway::Gateway; #[tokio::test] async fn renewable_access_keeps_token_in_memory_and_revokes_at_end() { @@ -207,6 +16,7 @@ async fn renewable_access_keeps_token_in_memory_and_revokes_at_end() { &ModelBindingId::new("binding-1"), &ExecutionId::new("execution-1"), root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await .unwrap(); @@ -272,6 +82,7 @@ async fn foreign_data_endpoint_is_rejected_and_grant_revoked() { &ModelBindingId::new("binding-1"), &ExecutionId::new("execution-1"), root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await; let Err(error) = result else { @@ -303,6 +114,7 @@ async fn denied_renewal_notifies_the_conversation_to_stop() { &ModelBindingId::new("binding-1"), &ExecutionId::new("execution-1"), root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await .unwrap(); @@ -330,6 +142,7 @@ async fn close_cancels_the_scheduled_renewal() { &ModelBindingId::new("binding-1"), &ExecutionId::new("execution-1"), root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await .unwrap(); @@ -346,7 +159,7 @@ fn anthropic_configuration_preserves_model_ids_and_isolates_cli_storage() { grant_id: "grant-1".into(), token: "synthetic-token".into(), expires_at: chrono::Local::now().fixed_offset(), - protocol: Protocol::AnthropicMessages, + protocol: ora_node_protocol::ModelProtocol::AnthropicMessages, proxy_base_url: "https://gateway/runtime/anthropic/v1".into(), model: Model { id: "team/model".into(), @@ -408,7 +221,7 @@ fn runtime_state_refuses_existing_files_and_symlinks_without_changing_them() { grant_id: "grant-1".into(), token: "synthetic-token".into(), expires_at: chrono::Local::now().fixed_offset(), - protocol: Protocol::OpenaiCompletions, + protocol: ora_node_protocol::ModelProtocol::OpenaiCompletions, proxy_base_url: "https://gateway/runtime/openai/v1".into(), model: Model { id: "model".into(), @@ -426,3 +239,164 @@ fn runtime_state_refuses_existing_files_and_symlinks_without_changing_them() { assert!(opencode::environment(&grant, &root.path().join("ca.pem"), root.path()).is_err()); assert_eq!(std::fs::read_dir(checkout.path()).unwrap().count(), 0); } + +/// The frozen configuration the fixture gateway's grant serves. +fn adapter_configuration() -> FrozenAgentConfiguration { + FrozenAgentConfiguration { + plugin_id: ora_node_protocol::PluginId::new("official/ora-space.claude"), + plugin_version: ora_node_protocol::PluginVersion::new("0.4.0"), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: ora_node_protocol::Sha256Digest::new("a".repeat(64)), + schema_version: 2, + declaration_fingerprint: ora_node_protocol::Sha256Digest::new("b".repeat(64)), + adapter_version: 1, + configuration_revision: 4, + settings_json: r#"{"effort":"high"}"#.into(), + protocol: ModelProtocol::OpenaiCompletions, + model: ora_node_protocol::BoundModel { + id: "team/model".into(), + name: "Fixture".into(), + context_window: 32768, + max_tokens: 1024, + }, + } +} + +/// On the adapter route the Node writes no CLI configuration and keeps the token out of every +/// environment variable; only the startup context, which the runtime never persists, carries it. +#[tokio::test] +async fn adapter_route_hands_the_token_only_to_the_startup_context() { + let root = tempfile::tempdir().unwrap(); + let gateway = Gateway::new(root.path()); + let configuration = adapter_configuration(); + let mut access = ModelAccess::open( + gateway.config.clone(), + &ModelBindingId::new("binding-1"), + &ExecutionId::new("execution-1"), + root.path(), + ModelRoute::Adapter(&configuration), + ) + .await + .unwrap(); + let home = PathBuf::from(&access.environment["HOME"]); + assert!(home.starts_with(root.path().join("model-runtime"))); + assert_eq!( + access.environment.keys().cloned().collect::>(), + vec![ + "HOME", + "NODE_EXTRA_CA_CERTS", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + ] + ); + assert!( + access + .environment + .values() + .all(|value| !value.contains("synthetic-temporary-token")) + ); + let port = Url::parse(&gateway.config.gateway_url) + .unwrap() + .port() + .unwrap(); + assert_eq!( + access.context, + Some(AgentModelContext { + configuration: ora_plugin_protocol::AgentModelConfiguration { + plugin_id: "official/ora-space.claude".into(), + plugin_version: "0.4.0".into(), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: "a".repeat(64), + schema_version: 2, + declaration_fingerprint: "b".repeat(64), + adapter_version: 1, + configuration_revision: 4, + settings_json: r#"{"effort":"high"}"#.into(), + protocol: ora_plugin_protocol::AgentModelProtocol::OpenaiCompletions, + model: ora_plugin_protocol::AgentBoundModel { + id: "team/model".into(), + name: "Fixture".into(), + context_window: 32768, + max_tokens: 1024, + }, + }, + proxy_base_url: format!("https://localhost:{port}/runtime/openai/v1"), + token: "synthetic-temporary-token".into(), + }) + ); + access.close().await; + drop(access); + assert!(!home.exists()); + assert_eq!( + gateway + .requests + .lock() + .unwrap() + .iter() + .map(|(route, _)| route.clone()) + .filter(|route| !route.contains("/renew ")) + .collect::>(), + vec![ + "POST /internal/v1/model-grants HTTP/1.1", + "DELETE /internal/v1/model-grants/grant-1 HTTP/1.1" + ] + ); +} + +/// A grant that serves another model or protocol than the frozen configuration is revoked before +/// any CLI state exists: the binding and the admitted configuration disagree. +#[tokio::test] +async fn adapter_route_revokes_a_grant_that_differs_from_the_frozen_configuration() { + let mismatches = [ + FrozenAgentConfiguration { + protocol: ModelProtocol::AnthropicMessages, + ..adapter_configuration() + }, + FrozenAgentConfiguration { + model: ora_node_protocol::BoundModel { + id: "team/other".into(), + ..adapter_configuration().model + }, + ..adapter_configuration() + }, + FrozenAgentConfiguration { + model: ora_node_protocol::BoundModel { + max_tokens: 2048, + ..adapter_configuration().model + }, + ..adapter_configuration() + }, + ]; + for configuration in mismatches { + let root = tempfile::tempdir().unwrap(); + let gateway = Gateway::new(root.path()); + let result = ModelAccess::open( + gateway.config.clone(), + &ModelBindingId::new("binding-1"), + &ExecutionId::new("execution-1"), + root.path(), + ModelRoute::Adapter(&configuration), + ) + .await; + let Err(error) = result else { + panic!("a mismatched grant must not start an adapter") + }; + assert_eq!(error.to_string(), "model_grant_mismatch"); + assert!(!root.path().join("model-runtime").exists()); + assert_eq!( + gateway + .requests + .lock() + .unwrap() + .iter() + .map(|(route, _)| route.clone()) + .collect::>(), + vec![ + "POST /internal/v1/model-grants HTTP/1.1", + "DELETE /internal/v1/model-grants/grant-1 HTTP/1.1" + ] + ); + } +} diff --git a/apps/ora-node/src/ora_git/tests.rs b/apps/ora-node/src/ora_git/tests.rs index 29d26fad3..d485fca2d 100644 --- a/apps/ora-node/src/ora_git/tests.rs +++ b/apps/ora-node/src/ora_git/tests.rs @@ -214,6 +214,7 @@ fn session_start(node: &NodeId) -> StartAgentSessionMessage { agent_plugin_version: PluginVersion::new("1.0.0"), checkout_execution_id: ExecutionId::new("clone"), model_binding_id: None, + agent_configuration: None, git_identity: GitIdentity { name: "User".into(), email: "user@example.com".into(), diff --git a/apps/ora-node/src/service.rs b/apps/ora-node/src/service.rs index 2654bdaa9..b50106285 100644 --- a/apps/ora-node/src/service.rs +++ b/apps/ora-node/src/service.rs @@ -145,6 +145,8 @@ struct SessionInfo { identity: NodeRuntimeIdentity, controller: ControllerId, capabilities: Vec, + /// Advertised beside `model_proxy`; absent when this Node cannot run plugin-owned adapters. + model_adapter: Option, } struct StopOnDrop(Shutdown); diff --git a/apps/ora-node/src/service/agents.rs b/apps/ora-node/src/service/agents.rs index b2defa3e5..9e43e88ca 100644 --- a/apps/ora-node/src/service/agents.rs +++ b/apps/ora-node/src/service/agents.rs @@ -440,6 +440,14 @@ pub(super) fn start( if input.payload.spec.model_binding_id.is_some() && !host.model_capable() { return Err(crate::Error::UnsupportedMessage); } + // The Controller dispatches only on an advertised match; anything else is a protocol fault. + if let Some(configuration) = &input.payload.spec.agent_configuration + && !host + .model_adapter() + .is_some_and(|adapter| adapter.admits(configuration)) + { + return Err(crate::Error::UnsupportedMessage); + } node.database.check_controller_execution( controller, &input.operation_id, diff --git a/apps/ora-node/src/service/session.rs b/apps/ora-node/src/service/session.rs index 21f9f2445..cdbb5c87c 100644 --- a/apps/ora-node/src/service/session.rs +++ b/apps/ora-node/src/service/session.rs @@ -302,6 +302,7 @@ async fn connected( selected_version: CURRENT_PROTOCOL_VERSION, node: info.identity.clone(), capabilities: info.capabilities.clone(), + model_adapter: info.model_adapter.clone(), }, }); timeout(deadline, transmit(writer, &response)) diff --git a/apps/ora-node/src/service/worker.rs b/apps/ora-node/src/service/worker.rs index 4309c8ff2..e1881af8d 100644 --- a/apps/ora-node/src/service/worker.rs +++ b/apps/ora-node/src/service/worker.rs @@ -135,7 +135,9 @@ pub(super) fn run( if agents.is_some() && revisions.is_some() { capabilities.push(NodeCapability::RevisionRestore); } + let model_adapter = agents.as_ref().and_then(agents::SessionHost::model_adapter); let info = SessionInfo { + model_adapter, agents: agents.clone(), grants, identity: node.identity().clone(), diff --git a/apps/ora-node/src/session.rs b/apps/ora-node/src/session.rs index fdcac10ab..cb819f497 100644 --- a/apps/ora-node/src/session.rs +++ b/apps/ora-node/src/session.rs @@ -172,6 +172,22 @@ impl AgentSessions { pub(crate) fn model_capable(&self) -> bool { self.shared.config.model_proxy.is_some() } + + /// The adapter path needs model access and a target plugin packages are built for; a host + /// outside the supported Linux targets keeps only the legacy OpenCode route. + pub(crate) fn model_adapter(&self) -> Option { + let target = ora_plugin_registry::current_host_target()?; + (self.model_capable() && target.as_str().ends_with("-unknown-linux-gnu")).then(|| { + ora_node_protocol::ModelAdapterCapability { + contract_version: ora_node_protocol::MODEL_ADAPTER_CONTRACT_VERSION, + target: target.as_str().to_string(), + protocols: vec![ + ora_node_protocol::ModelProtocol::OpenaiCompletions, + ora_node_protocol::ModelProtocol::AnthropicMessages, + ], + } + }) + } /// A lost actor or failed terminal write stops admission instead of stranding a Running row. pub(crate) fn failed(&self) -> bool { self.shared diff --git a/apps/ora-node/src/session/driver.rs b/apps/ora-node/src/session/driver.rs index 2f8332df2..0827e1010 100644 --- a/apps/ora-node/src/session/driver.rs +++ b/apps/ora-node/src/session/driver.rs @@ -12,7 +12,7 @@ use super::queue::{Plan, plan}; use super::resume; use super::workload::SessionPlacement; use super::{SessionConfig, Shared}; -use crate::model_proxy::{ModelAccess, ModelAccessStatus}; +use crate::model_proxy::{ModelAccess, ModelAccessStatus, ModelRoute}; use agent_client_protocol_schema::v1::{ContentBlock as AcpContentBlock, MessageId, TextContent}; use ora_agent_runtime::{AgentRuntimeManager, AgentRuntimeSetup, MemorySessionStore, NoSessionMcp}; use ora_contracts::{ @@ -170,8 +170,14 @@ where let Some(config) = shared.config.model_proxy.clone() else { return SessionEnd::agent_failed("model_proxy_unavailable"); }; - match ModelAccess::open(config, binding, execution, &shared.config.home_directory).await - { + // A frozen configuration hands the grant to the plugin's adapter; without one the + // package is a legacy OpenCode build whose configuration the Node still writes. + let route = spec + .agent_configuration + .as_ref() + .map_or(ModelRoute::LegacyOpenCode, ModelRoute::Adapter); + let home = &shared.config.home_directory; + match ModelAccess::open(config, binding, execution, home, route).await { Ok(access) => Some(access), Err(error) if error.0 == "model_session_ending" => { return match super::model::reconcile( @@ -281,6 +287,9 @@ where lifecycle: lifecycle.clone(), taps, plugin_id: plugin_id.clone(), + model_context: model_access + .as_ref() + .and_then(|access| access.context.clone()), }), store: MemorySessionStore::default(), directory: CheckoutDirectory(checkout.clone()), diff --git a/apps/ora-node/src/session/host.rs b/apps/ora-node/src/session/host.rs index ed80a1a3e..4b4d23650 100644 --- a/apps/ora-node/src/session/host.rs +++ b/apps/ora-node/src/session/host.rs @@ -146,11 +146,18 @@ pub(super) struct SessionPlugin { pub(super) lifecycle: SessionLifecycle, pub(super) taps: GenerationTaps, pub(super) plugin_id: PluginId, + /// The adapter's live startup input; a restart of the same plugin reapplies the same grant. + pub(super) model_context: Option, } impl AgentAttach for SessionPlugin { type StopError = PluginLifecycleError; + /// Only the session's own frozen context; the runtime refuses ACP until the adapter confirms it. + fn model_context(&self) -> Option { + self.model_context.clone() + } + /// The session verified this plugin's version before composing the runtime, so it is the /// whole answer; any other installed plugin stays invisible and never starts. fn installed_agent_plugins(&self) -> Vec { diff --git a/apps/ora-node/src/session/model/tests.rs b/apps/ora-node/src/session/model/tests.rs index 9418824e4..f68b64942 100644 --- a/apps/ora-node/src/session/model/tests.rs +++ b/apps/ora-node/src/session/model/tests.rs @@ -190,6 +190,7 @@ async fn initial_grant_ending_preserves_delayed_user_end_and_seals_empty_history &ModelBindingId::new("binding-1"), &execution, root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await; let Err(error) = grant else { @@ -255,6 +256,7 @@ async fn production_driver_reconciles_early_user_end_without_launching_a_plugin( ora_node_protocol::OperationId::new("operation-1"), execution.clone(), AgentSessionSpec { + agent_configuration: None, node_id: node.node_id.clone(), agent_plugin_id: PluginId::new("official/ora-space.echo"), agent_plugin_version: PluginVersion::new("1.0.0"), @@ -311,6 +313,7 @@ async fn renewal_ending_revokes_before_reconciling_exact_cancelled_reason() { &ModelBindingId::new("binding-1"), &execution, root.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await .unwrap(); diff --git a/apps/ora-node/src/session/workload/tests.rs b/apps/ora-node/src/session/workload/tests.rs index b74c43242..856a8d43a 100644 --- a/apps/ora-node/src/session/workload/tests.rs +++ b/apps/ora-node/src/session/workload/tests.rs @@ -245,6 +245,7 @@ async fn model_runtime_uses_workload_home_and_only_publishes_public_trust() { &ModelBindingId::new("binding-1"), &execution(), layout._temp.path(), + crate::model_proxy::ModelRoute::LegacyOpenCode, ) .await .expect("grant"); diff --git a/apps/ora-node/tests/agent_session.rs b/apps/ora-node/tests/agent_session.rs index c3c49a8aa..af124eced 100644 --- a/apps/ora-node/tests/agent_session.rs +++ b/apps/ora-node/tests/agent_session.rs @@ -120,6 +120,7 @@ async fn unconfigured_model_proxy_ends_before_launching_the_plugin() { OperationId::new(OPERATION), ExecutionId::new(EXECUTION), AgentSessionSpec { + agent_configuration: None, node_id: fixture.node().node_id, agent_plugin_id: PluginId::new(PLUGIN_ID), agent_plugin_version: PluginVersion::new(PLUGIN_VERSION), @@ -668,3 +669,104 @@ async fn a_separate_workload_runs_the_agent_from_its_session_directory() { ), ); } + +/// Grant routes the gateway fixture saw, without the renewals a slow run may add. +fn grant_routes(gateway: &gateway::Gateway) -> Vec { + gateway + .requests + .lock() + .unwrap() + .iter() + .map(|(route, _)| route.clone()) + .filter(|route| !route.contains("/renew ")) + .collect() +} + +/// A configured session hands its grant to the adapter in the startup context alone: the adapter +/// answers with the frozen model and the platform proxy, the token never reaches its environment, +/// history or Thread, and ending the session revokes the grant. +#[tokio::test(flavor = "multi_thread")] +async fn a_configured_adapter_receives_its_grant_only_in_the_startup_context() { + let fixture = Fixture::new(); + let gateway = gateway::Gateway::new(&fixture.home()); + let sessions = fixture.model_sessions(gateway.config.clone()); + fixture.start_configured(&sessions, "[model]", r#"{"effort":"high"}"#); + let answer = until(|| { + fixture.ledger.events().iter().find_map(|event| { + (field(&event.record, &["update", "sessionUpdate"]) == "agent_message_chunk") + .then(|| field(&event.record, &["update", "content", "text"])) + }) + }) + .await; + fixture.command( + &sessions, + "command-2", + SessionCommand::EndSession(EndSessionReason::UserEnded), + ); + let ended = fixture.ended().await; + sessions.shutdown().await; + let port = url::Url::parse(&gateway.config.gateway_url) + .unwrap() + .port() + .unwrap(); + assert_eq!( + (answer, ended.reason), + ( + format!( + "model=team/model proxy=https://localhost:{port}/runtime/openai/v1 token_in_environment=false" + ), + AgentSessionEndReason::UserEnded, + ) + ); + let history = serde_json::to_string(&fixture.history_lines()).unwrap(); + let thread = format!("{:?}", fixture.ledger.events()); + assert!(!history.contains("synthetic-temporary-token")); + assert!(!thread.contains("synthetic-temporary-token")); + assert_eq!( + grant_routes(&gateway), + vec![ + "POST /internal/v1/model-grants HTTP/1.1", + "DELETE /internal/v1/model-grants/grant-1 HTTP/1.1" + ] + ); + assert!( + fixture + .plugin_pids() + .into_iter() + .all(|pid| !process_exists(pid)) + ); +} + +/// An adapter that does not confirm the exact configuration it was given never receives a prompt: +/// the session fails before ACP starts, revokes the grant and stops the plugin. +#[tokio::test(flavor = "multi_thread")] +async fn an_unconfirmed_adapter_never_receives_a_prompt() { + let fixture = Fixture::new(); + let gateway = gateway::Gateway::new(&fixture.home()); + let sessions = fixture.model_sessions(gateway.config.clone()); + fixture.start_configured(&sessions, "must not be sent", r#"{"refuse":true}"#); + let ended = fixture.ended().await; + sessions.shutdown().await; + assert_eq!( + (ended.reason, ended.detail.as_deref()), + ( + AgentSessionEndReason::AgentFailed, + Some("agent_unavailable") + ) + ); + assert_eq!(fixture.ledger.events(), Vec::new()); + assert_eq!( + grant_routes(&gateway), + vec![ + "POST /internal/v1/model-grants HTTP/1.1", + "DELETE /internal/v1/model-grants/grant-1 HTTP/1.1" + ] + ); + assert!(!fixture.plugin_pids().is_empty()); + assert!( + fixture + .plugin_pids() + .into_iter() + .all(|pid| !process_exists(pid)) + ); +} diff --git a/apps/ora-node/tests/agent_session/support.rs b/apps/ora-node/tests/agent_session/support.rs index c7189a4ea..433e9fdd0 100644 --- a/apps/ora-node/tests/agent_session/support.rs +++ b/apps/ora-node/tests/agent_session/support.rs @@ -1,14 +1,15 @@ //! In-memory ledger, checkout and plugin catalog, plus a Node home with the echo agent installed. use ora_node::{ - AgentSessions, CheckoutResolver, CommandSettlement, PluginCatalog, PriorRevisionRestore, - QueuedCommand, RestoreCancel, RestoreFailure, RestoreRequest, Restored, SessionCommand, - SessionConfig, SessionHost, SessionLedger, SessionWorkload, + AgentSessions, CheckoutResolver, CommandSettlement, ModelProxyConfig, PluginCatalog, + PriorRevisionRestore, QueuedCommand, RestoreCancel, RestoreFailure, RestoreRequest, Restored, + SessionCommand, SessionConfig, SessionHost, SessionLedger, SessionWorkload, }; use ora_node_protocol::{ - AgentSessionEnded, AgentSessionSpec, CommandId, ContentBlock, ExecutionId, GitIdentity, NodeId, + AgentSessionEnded, AgentSessionSpec, BoundModel, CommandId, ContentBlock, ExecutionId, + FrozenAgentConfiguration, GitIdentity, ModelBindingId, ModelProtocol, NodeId, NodeIncarnationId, NodeRuntimeIdentity, OperationId, PluginId, PluginVersion, PriorRevision, - Sequence, ThreadEvent, TurnId, UserTurn, + Sequence, Sha256Digest, ThreadEvent, TurnId, UserTurn, }; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -18,6 +19,12 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError, mpsc}; use std::time::{Duration, Instant}; +/// The model-gateway fixture the crate's unit tests use, serving the real grant contract. +// Failure toggles exist for the unit tests; these tests use only the default behavior. +#[allow(dead_code)] +#[path = "../../src/model_proxy/test_gateway.rs"] +pub mod gateway; + pub const PLUGIN_ID: &str = "official/ora-space.echo"; pub const PLUGIN_VERSION: &str = "1.0.0"; pub const EXECUTION: &str = "8b0e5a52-6f1c-4c55-9d3e-2a7b1f0c9e41"; @@ -404,13 +411,28 @@ impl Fixture { /// Composes session execution as [`Self::sessions`] does, running agents per `workload`. pub fn sessions_with(&self, installed_version: &str, workload: SessionWorkload) -> Sessions { + self.compose(installed_version, workload, /*model_proxy*/ None) + } + + /// Composes session execution whose model access goes through the gateway at `proxy`. + pub fn model_sessions(&self, proxy: ModelProxyConfig) -> Sessions { + self.compose(PLUGIN_VERSION, SessionWorkload::Shared, Some(proxy)) + } + + /// Composes session execution over the fixture's ledger, checkout and catalog. + fn compose( + &self, + installed_version: &str, + workload: SessionWorkload, + model_proxy: Option, + ) -> Sessions { AgentSessions::new( SessionConfig { home_directory: self.home(), deno_path: env!("CARGO_BIN_EXE_ora-node-echo-agent").into(), timezone: chrono_tz::UTC, agent_ready_timeout: Duration::from_secs(30), - model_proxy: None, + model_proxy, workload, }, self.node(), @@ -450,6 +472,7 @@ impl Fixture { OperationId::new(OPERATION), ExecutionId::new(EXECUTION), AgentSessionSpec { + agent_configuration: None, node_id: NodeId::new("node-1"), agent_plugin_id: PluginId::new(PLUGIN_ID), agent_plugin_version: PluginVersion::new(version), @@ -463,6 +486,43 @@ impl Fixture { ); } + /// Starts a session whose adapter receives `settings_json` with the gateway fixture's model. + pub fn start_configured(&self, sessions: &Sessions, initial: &str, settings_json: &str) { + sessions.start( + OperationId::new(OPERATION), + ExecutionId::new(EXECUTION), + AgentSessionSpec { + agent_configuration: Some(FrozenAgentConfiguration { + plugin_id: PluginId::new(PLUGIN_ID), + plugin_version: PluginVersion::new(PLUGIN_VERSION), + target: "x86_64-unknown-linux-gnu".into(), + package_sha256: Sha256Digest::new("a".repeat(64)), + schema_version: 2, + declaration_fingerprint: Sha256Digest::new("b".repeat(64)), + adapter_version: 1, + configuration_revision: 4, + settings_json: settings_json.into(), + protocol: ModelProtocol::OpenaiCompletions, + model: BoundModel { + id: "team/model".into(), + name: "Fixture".into(), + context_window: 32768, + max_tokens: 1024, + }, + }), + node_id: NodeId::new("node-1"), + agent_plugin_id: PluginId::new(PLUGIN_ID), + agent_plugin_version: PluginVersion::new(PLUGIN_VERSION), + checkout_execution_id: ExecutionId::new(CHECKOUT_EXECUTION), + model_binding_id: Some(ModelBindingId::new("binding-1")), + git_identity: identity(), + initial_turn: turn("turn-1", initial), + prior_revision: None, + workspace_directory: vec![], + }, + ); + } + /// Persists one command and wakes the session, as the protocol side does. pub fn command(&self, sessions: &Sessions, command_id: &str, command: SessionCommand) { self.ledger.accept(command_id, command); diff --git a/apps/ora-node/tests/session_ledger.rs b/apps/ora-node/tests/session_ledger.rs index 1fad38afb..ee16a809b 100644 --- a/apps/ora-node/tests/session_ledger.rs +++ b/apps/ora-node/tests/session_ledger.rs @@ -24,6 +24,7 @@ fn runtime_ports_use_durable_ledger() { execution_id: ExecutionId::new("execution"), payload: StartAgentSession { spec: AgentSessionSpec { + agent_configuration: None, node_id: NodeId::new("node"), agent_plugin_id: PluginId::new("official/agent"), agent_plugin_version: PluginVersion::new("1.0.0"), diff --git a/apps/ora-node/tests/standalone/agent_sessions/support.rs b/apps/ora-node/tests/standalone/agent_sessions/support.rs index e37852102..55dbba91d 100644 --- a/apps/ora-node/tests/standalone/agent_sessions/support.rs +++ b/apps/ora-node/tests/standalone/agent_sessions/support.rs @@ -17,6 +17,7 @@ pub(super) fn start(text: &str) -> StartAgentSessionMessage { execution_id: ExecutionId::new(EXECUTION), payload: StartAgentSession { spec: AgentSessionSpec { + agent_configuration: None, node_id: NodeId::new("test-node"), agent_plugin_id: PluginId::new("official/ora-space.echo"), agent_plugin_version: PluginVersion::new("1.0.0"), From ea004b4fbff758152489c17ff556bd96cc090173 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 14:42:46 +0000 Subject: [PATCH 05/18] fix(node-protocol): accept a frozen configuration that was never saved An Agent that keeps using the personal model default and declares no Settings, such as a declared OpenCode, is frozen with configuration revision 0. Refusing it left the session queued in Cloud until its startup budget failed it with agent_start_failed. --- crates/node-protocol/src/domain/model_adapter.rs | 3 ++- .../node-protocol/tests/protocol/agent_session.rs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/node-protocol/src/domain/model_adapter.rs b/crates/node-protocol/src/domain/model_adapter.rs index 0ff15c48a..05aae8420 100644 --- a/crates/node-protocol/src/domain/model_adapter.rs +++ b/crates/node-protocol/src/domain/model_adapter.rs @@ -63,6 +63,8 @@ pub struct FrozenAgentConfiguration { pub schema_version: u32, pub declaration_fingerprint: Sha256Digest, pub adapter_version: u32, + /// Zero when the plugin declares no Settings and the user never saved a configuration, as for + /// an Agent that keeps using the personal model default. pub configuration_revision: u64, /// Validated Settings kept as JSON text so numeric lexemes survive every hop unchanged. pub settings_json: String, @@ -88,7 +90,6 @@ impl FrozenAgentConfiguration { || self.target.is_empty() || self.schema_version == 0 || self.adapter_version != MODEL_ADAPTER_CONTRACT_VERSION - || self.configuration_revision == 0 || !settings_object || self.model.id.is_empty() || self.model.name.is_empty() diff --git a/crates/node-protocol/tests/protocol/agent_session.rs b/crates/node-protocol/tests/protocol/agent_session.rs index 021febfd1..b257c4c31 100644 --- a/crates/node-protocol/tests/protocol/agent_session.rs +++ b/crates/node-protocol/tests/protocol/agent_session.rs @@ -248,6 +248,19 @@ async fn configured_session_round_trips_and_rejects_inconsistent_configuration() }); case.assert_wire().await?; case.assert_round_trip().await?; + // An Agent on the personal model default with no declared Settings was never saved, so its + // frozen revision is zero; the rejections below each fail for their own field from that base. + let Message::Controller(ControllerToNodeMessage::StartAgentSession(command)) = + &mut case.message + else { + panic!("fixture must be a session start") + }; + if let Some(configuration) = command.payload.spec.agent_configuration.as_mut() { + configuration.configuration_revision = 0; + } + case.wire["payload"]["spec"]["agent_configuration"]["configuration_revision"] = json!(0); + case.assert_wire().await?; + case.assert_round_trip().await?; let configuration = "/payload/spec/agent_configuration"; for (path, value) in [ ( @@ -257,7 +270,6 @@ async fn configured_session_round_trips_and_rejects_inconsistent_configuration() (format!("{configuration}/plugin_version"), json!("1.2.1")), (format!("{configuration}/target"), json!("")), (format!("{configuration}/adapter_version"), json!(2)), - (format!("{configuration}/configuration_revision"), json!(0)), (format!("{configuration}/settings_json"), json!("[1]")), (format!("{configuration}/settings_json"), json!("{")), (format!("{configuration}/model/id"), json!("")), From ffb18ce60d8c6752954ab332e282f6635b9c7f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:07:45 +0800 Subject: [PATCH 06/18] feat(process): support scoped empty child environments --- crates/process/src/lib.rs | 2 +- crates/process/src/tokio_process.rs | 23 ++++++++++++++++++++++- crates/process/tests/environment.rs | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 crates/process/tests/environment.rs diff --git a/crates/process/src/lib.rs b/crates/process/src/lib.rs index c81b1bcba..ff3efbf01 100644 --- a/crates/process/src/lib.rs +++ b/crates/process/src/lib.rs @@ -8,5 +8,5 @@ mod tree; pub use identity::ProcessIdentity; pub use reaper::{initialize_reaper, run_reaper, shutdown_reaper}; pub use spec::{ProcessSpec, ProcessStdio}; -pub use tokio_process::{TokioManagedProcess, TokioProcessSpawner}; +pub use tokio_process::{ProcessEnvironment, TokioManagedProcess, TokioProcessSpawner}; pub use traits::{ManagedProcess, ProcessSpawner}; diff --git a/crates/process/src/tokio_process.rs b/crates/process/src/tokio_process.rs index 8982c73f3..2f965000e 100644 --- a/crates/process/src/tokio_process.rs +++ b/crates/process/src/tokio_process.rs @@ -15,6 +15,15 @@ use crate::{ManagedProcess, ProcessIdentity, ProcessSpawner, ProcessSpec}; #[derive(Debug, Clone, Copy, Default)] pub struct TokioProcessSpawner { identity: ProcessIdentity, + environment: ProcessEnvironment, +} + +/// Whether host environment authority is inherited by every child of this spawner. +#[derive(Debug, Clone, Copy, Default)] +pub enum ProcessEnvironment { + #[default] + Inherit, + Empty, } impl TokioProcessSpawner { @@ -25,7 +34,16 @@ impl TokioProcessSpawner { /// Creates a spawner whose children run as `identity` instead of inheriting this process's /// credentials. A child that cannot take the identity never executes: its spawn fails. pub fn running_as(identity: ProcessIdentity) -> Self { - Self { identity } + Self { + identity, + environment: ProcessEnvironment::Inherit, + } + } + + /// Configures inheritance once for a scoped owner; explicit ProcessSpec envs still apply. + pub fn with_environment(mut self, environment: ProcessEnvironment) -> Self { + self.environment = environment; + self } } @@ -72,6 +90,9 @@ impl ProcessSpawner for TokioProcessSpawner { })?; let reaper_registration = spec.reaper_registration(); let mut command = Command::new(spec.program()); + if let ProcessEnvironment::Empty = self.environment { + command.env_clear(); + } command.args(spec.args_iter()); ora_utils::process::hide_console_window(command.as_std_mut()); diff --git a/crates/process/tests/environment.rs b/crates/process/tests/environment.rs new file mode 100644 index 000000000..0a09edb47 --- /dev/null +++ b/crates/process/tests/environment.rs @@ -0,0 +1,22 @@ +#![cfg(unix)] + +use ora_process::{ + ManagedProcess, ProcessEnvironment, ProcessSpawner, ProcessSpec, TokioProcessSpawner, +}; +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; + +/// A real process receives only explicitly supplied variables when its owner clears inheritance. +#[tokio::test] +async fn empty_environment_does_not_inherit_host_authority() +-> Result<(), Box> { + let spawner = TokioProcessSpawner::new().with_environment(ProcessEnvironment::Empty); + let mut process = + spawner.spawn(ProcessSpec::new("/usr/bin/env").env("CIRCUS_VISIBLE", "explicit"))?; + let mut stdout = process.take_stdout().ok_or("stdout unavailable")?; + let mut output = String::new(); + stdout.read_to_string(&mut output).await?; + assert!(process.wait().await?.success()); + assert_eq!(output, "CIRCUS_VISIBLE=explicit\n"); + Ok(()) +} From adbdbc6188b15d3f426731c2998e9a72caa4c936 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:47:49 +0000 Subject: [PATCH 07/18] test(node): give newer session fixtures an explicit agent configuration --- apps/ora-node/src/managed/restore_tests.rs | 1 + apps/ora-node/src/session/model/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/ora-node/src/managed/restore_tests.rs b/apps/ora-node/src/managed/restore_tests.rs index 8566161cd..ca205a068 100644 --- a/apps/ora-node/src/managed/restore_tests.rs +++ b/apps/ora-node/src/managed/restore_tests.rs @@ -28,6 +28,7 @@ fn session() -> StartAgentSessionMessage { payload: StartAgentSession { spec: AgentSessionSpec { workspace_directory: Vec::new(), + agent_configuration: None, node_id: NodeId::new("node"), agent_plugin_id: PluginId::new("official/agent"), agent_plugin_version: PluginVersion::new("1.0.0"), diff --git a/apps/ora-node/src/session/model/tests.rs b/apps/ora-node/src/session/model/tests.rs index f68b64942..71d9680d8 100644 --- a/apps/ora-node/src/session/model/tests.rs +++ b/apps/ora-node/src/session/model/tests.rs @@ -439,6 +439,7 @@ fn production_driver_defers_unknown_restore_without_releasing_terminal_history() checkout_execution_id: ExecutionId::new("clone"), model_binding_id: None, git_identity: GitIdentity { + agent_configuration: None, name: "Fixture".into(), email: "fixture@example.test".into(), }, From f51c87c6c0fa51b363457babb2419a3df3408170 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:47:49 +0000 Subject: [PATCH 08/18] feat(plugin-config): validate frozen settings against an installed declaration Expose the Settings validation the editor uses for a compiled Agent declaration, and let a host compile the declaration an installed package actually carries, so a configured session can re-check both at start. --- crates/plugin-config/src/agent.rs | 42 +++++++++++++++++++ crates/plugin-config/src/agent/tests.rs | 32 +++++++++++++- crates/plugin-config/src/lib.rs | 2 +- crates/plugin-manager/src/install.rs | 2 +- .../plugin-manager/src/install/declaration.rs | 21 +++++++++- crates/plugin-manager/src/lib.rs | 3 +- 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/plugin-config/src/agent.rs b/crates/plugin-config/src/agent.rs index 31aad6bb3..6bfb49f76 100644 --- a/crates/plugin-config/src/agent.rs +++ b/crates/plugin-config/src/agent.rs @@ -47,6 +47,48 @@ impl CompiledAgentConfiguration { } } +/// Validates frozen effective Settings with the editor's scalar and completeness rules. +/// +/// A host re-checks the values Cloud froze against the declaration it actually installed, so a +/// configuration saved for another declaration cannot start an adapter. +pub fn validate_agent_settings( + declaration: &CompiledAgentConfiguration, + settings: &serde_json::Map, +) -> bool { + let Ok(values) = settings + .iter() + .map(|(key, value)| { + serde_json::from_value::(value.clone()) + .map(|value| (key.clone(), value)) + }) + .collect::, _>>() + else { + return false; + }; + let compiled = CompiledDeclaration { + schema_version: 1, + settings: declaration.settings.clone(), + fingerprint: declaration.fingerprint.clone(), + }; + if !crate::values::validate_values(&compiled, &values).is_empty() { + return false; + } + let details = crate::values::details_from( + compiled, + crate::values::StoredConfiguration { + schema_version: 1, + revision: 0, + values, + }, + ); + matches!( + details.summary, + crate::ConfigurationSummary::Available { + completeness: crate::ConfigurationCompleteness::Complete + } + ) +} + /// What a plugin can consume; actual protocol availability and authorization remain host-owned. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/plugin-config/src/agent/tests.rs b/crates/plugin-config/src/agent/tests.rs index a8c1b459c..268fdeacd 100644 --- a/crates/plugin-config/src/agent/tests.rs +++ b/crates/plugin-config/src/agent/tests.rs @@ -1,4 +1,4 @@ -use super::{CompileAgentConfigurationError, compile_agent_configuration}; +use super::{CompileAgentConfigurationError, compile_agent_configuration, validate_agent_settings}; use crate::mcp::{ CompileConfigurationFileError, CompiledConfigurationFile, compile_configuration_file, }; @@ -194,3 +194,33 @@ fn diagnostics_do_not_echo_rejected_values() -> TestResult { } Ok(()) } + +/// Frozen Settings must be declared, type-correct and complete for the installed declaration. +#[test] +fn validates_frozen_settings_against_the_declaration() -> TestResult { + let mut declaration: Value = serde_json::from_slice(MINIMAL)?; + declaration["settings"] = json!({ + "endpoint": {"type": "string", "title": "Endpoint", "description": "Service URL", "required": true}, + "verbose": {"type": "boolean", "title": "Verbose", "description": "Log more", "default": false} + }); + let compiled = compile_agent_configuration(&serde_json::to_vec(&declaration)?)?; + let settings = |value: Value| value.as_object().cloned().unwrap_or_default(); + assert!(validate_agent_settings( + &compiled, + &settings(json!({"endpoint": "https://x"})) + )); + assert!(!validate_agent_settings(&compiled, &settings(json!({})))); + assert!(!validate_agent_settings( + &compiled, + &settings(json!({"endpoint": 1})) + )); + assert!(!validate_agent_settings( + &compiled, + &settings(json!({"endpoint": "https://x", "unexpected": true})) + )); + assert!(validate_agent_settings( + &compile_agent_configuration(MINIMAL)?, + &serde_json::Map::new() + )); + Ok(()) +} diff --git a/crates/plugin-config/src/lib.rs b/crates/plugin-config/src/lib.rs index aae9e181c..3e1c81212 100644 --- a/crates/plugin-config/src/lib.rs +++ b/crates/plugin-config/src/lib.rs @@ -12,7 +12,7 @@ mod windows_permissions; pub use agent::{ AgentModelAccess, AgentModelCardinality, AgentModelProtocol, CompileAgentConfigurationError, - CompiledAgentConfiguration, compile_agent_configuration, + CompiledAgentConfiguration, compile_agent_configuration, validate_agent_settings, }; pub use declaration::{ CompileDeclarationError, CompiledDeclaration, MAX_DECLARATION_BYTES, MAX_SETTINGS, diff --git a/crates/plugin-manager/src/install.rs b/crates/plugin-manager/src/install.rs index f7ec166d8..a3162f90a 100644 --- a/crates/plugin-manager/src/install.rs +++ b/crates/plugin-manager/src/install.rs @@ -4,7 +4,7 @@ mod cache; pub(crate) mod declaration; pub(crate) mod package; -pub use declaration::{ExpectedAgentDeclaration, verify_agent_declaration}; +pub use declaration::{ExpectedAgentDeclaration, read_agent_declaration, verify_agent_declaration}; use crate::discovery::installed_root; use crate::limits::package_extract_limits; diff --git a/crates/plugin-manager/src/install/declaration.rs b/crates/plugin-manager/src/install/declaration.rs index 7498982e9..9a44cedd6 100644 --- a/crates/plugin-manager/src/install/declaration.rs +++ b/crates/plugin-manager/src/install/declaration.rs @@ -8,7 +8,9 @@ //! is rejected while it is still staged. use super::InstallError; -use ora_plugin_config::{MAX_DECLARATION_BYTES, compile_agent_configuration}; +use ora_plugin_config::{ + CompiledAgentConfiguration, MAX_DECLARATION_BYTES, compile_agent_configuration, +}; use ora_plugin_manifest::Sha256Digest; use std::io::Read; use std::path::Path; @@ -78,6 +80,23 @@ pub fn verify_agent_declaration( Ok(()) } +/// Compiles the declaration an installed package actually carries. +/// +/// A host that starts a configured session re-reads it under its package lease instead of +/// trusting the identity Cloud froze, so a replaced package or a configuration frozen for another +/// declaration is refused before any model authority is handed out. +pub fn read_agent_declaration( + package_root: &Path, +) -> Result { + let bytes = read_declaration_bytes(package_root)?; + compile_agent_configuration(&bytes).map_err(|error| { + InstallError::invalid_field( + CONFIGURATION_FILE, + format!("agent configuration does not compile: {error}"), + ) + }) +} + /// Reads the bounded declaration bytes, refusing anything but a regular file. /// /// Safe extraction already guarantees containment; the regular-file check here keeps a symlinked diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index b241b1c0b..3fa6e1efc 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -23,7 +23,8 @@ pub use discovery::{MANIFEST_FILE_NAME, installed_root}; pub use hook::{InstalledHookDescriptor, validate_executable_containment}; pub use install::{ ExpectedAgentDeclaration, HostTarget, InstallError, InstalledPackage, Installer, - ResolvedReleaseSource, UpdateError, select_release, verify_agent_declaration, + ResolvedReleaseSource, UpdateError, read_agent_declaration, select_release, + verify_agent_declaration, }; pub use issue::{PluginDiscoveryIssue, PluginDiscoveryIssueKind}; pub use mcp::{InstalledMcpDescriptor, MCP_CONFIGURATION_FILE}; From d3b7e936d6e675e80200e631b94836df090fc3ec Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:47:58 +0000 Subject: [PATCH 09/18] feat(node): prove the installed package before granting model access The installer records a receipt of the archive digest and target it verified, outside the package, and clears it before republishing. A configured session then re-checks under its package lease, before requesting a grant, that the receipt matches the frozen package, that the installed declaration's fingerprint, schema and adapter contract match, that the frozen Settings validate against it and that it offers the frozen protocol (ADR D2 step 1). A failure ends the session with agent_model_configuration_invalid or agent_model_protocol_mismatch and starts no plugin process. --- Cargo.lock | 1 + apps/ora-node/Cargo.toml | 1 + apps/ora-node/src/lib.rs | 7 +- apps/ora-node/src/plugins.rs | 25 +- apps/ora-node/src/plugins/catalog.rs | 24 +- .../src/plugins/model_configuration.rs | 261 ++++++++++++++++++ apps/ora-node/src/session.rs | 6 +- apps/ora-node/src/session/driver.rs | 7 + apps/ora-node/src/session/model/tests.rs | 6 + apps/ora-node/src/session/ports.rs | 27 ++ apps/ora-node/tests/agent_session.rs | 28 ++ apps/ora-node/tests/agent_session/support.rs | 23 +- 12 files changed, 397 insertions(+), 19 deletions(-) create mode 100644 apps/ora-node/src/plugins/model_configuration.rs diff --git a/Cargo.lock b/Cargo.lock index a3d278d1d..52f07b172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3940,6 +3940,7 @@ dependencies = [ "ora-node-db", "ora-node-protocol", "ora-node-transport", + "ora-plugin-config", "ora-plugin-lifecycle", "ora-plugin-manager", "ora-plugin-protocol", diff --git a/apps/ora-node/Cargo.toml b/apps/ora-node/Cargo.toml index 47c20cd9e..069c27cd0 100644 --- a/apps/ora-node/Cargo.toml +++ b/apps/ora-node/Cargo.toml @@ -36,6 +36,7 @@ ora-contracts.workspace = true ora-domain.workspace = true ora-effect.workspace = true ora-history.workspace = true +ora-plugin-config.workspace = true ora-plugin-lifecycle.workspace = true ora-plugin-manager.workspace = true ora-plugin-registry.workspace = true diff --git a/apps/ora-node/src/lib.rs b/apps/ora-node/src/lib.rs index def47ba8a..45302b159 100644 --- a/apps/ora-node/src/lib.rs +++ b/apps/ora-node/src/lib.rs @@ -60,9 +60,10 @@ pub use revision::RestoreConfig; pub use service::{AgentConfig, ControlConfig, ControlListen, RuntimeScope, ServiceConfig, serve}; #[cfg(target_os = "linux")] pub use session::{ - AgentSessions, CheckoutResolver, CommandSettlement, HistoryUnavailable, PluginCatalog, - PriorRevisionRestore, QueuedCommand, RestoreCancel, RestoreFailure, RestoreRequest, Restored, - SessionCommand, SessionConfig, SessionHost, SessionLedger, SessionWorkload, + AgentSessions, CheckoutResolver, CommandSettlement, HistoryUnavailable, + InstalledConfigurationError, PluginCatalog, PriorRevisionRestore, QueuedCommand, RestoreCancel, + RestoreFailure, RestoreRequest, Restored, SessionCommand, SessionConfig, SessionHost, + SessionLedger, SessionWorkload, }; #[cfg(target_os = "linux")] pub type ManagedNode = Node>; diff --git a/apps/ora-node/src/plugins.rs b/apps/ora-node/src/plugins.rs index 6207de1b0..d64aa5e05 100644 --- a/apps/ora-node/src/plugins.rs +++ b/apps/ora-node/src/plugins.rs @@ -2,6 +2,7 @@ //! checks the lock again so a session started during the transfer cannot lose its executable. mod catalog; mod config; +mod model_configuration; pub use catalog::{DirectoryPluginCatalog, PluginUseLease}; pub use config::PluginConfig; use ora_node_protocol::*; @@ -130,12 +131,6 @@ impl PluginInstaller { .as_ref() .map(declaration_expectation) .transpose()?; - if self - .catalog - .already_installed(&id, &version, declaration.as_ref())? - { - return Ok(()); - } let (download, target) = match &plugin.release { PluginRelease::Universal { download } => (download, None), PluginRelease::Targets { targets } => { @@ -150,6 +145,22 @@ impl PluginInstaller { (&selected.download, Some(host.clone())) } }; + // The receipt names the artifact bytes this Node verified, so a configured session can + // later prove it runs exactly the package Cloud froze. + let receipt_target = self + .host_target + .as_ref() + .map_or("", |target| target.as_str()); + let artifact = model_configuration::PackageArtifact { + sha256: download.sha256.as_str(), + target: receipt_target, + }; + if self + .catalog + .already_installed(&id, &version, declaration.as_ref(), &artifact)? + { + return Ok(()); + } let url = url::Url::parse(&download.url).map_err(|_| PluginFailureCode::InvalidPackage)?; // Plans contain download locations, not credentials. In particular reqwest must not // turn URL user-info into an Authorization header. @@ -186,7 +197,7 @@ impl PluginInstaller { ) .await .map_err(failure)?; - self.catalog.publish(&id, &version, prepared) + self.catalog.publish(&id, &version, prepared, &artifact) } } diff --git a/apps/ora-node/src/plugins/catalog.rs b/apps/ora-node/src/plugins/catalog.rs index 8d2f202e9..af8456949 100644 --- a/apps/ora-node/src/plugins/catalog.rs +++ b/apps/ora-node/src/plugins/catalog.rs @@ -1,4 +1,5 @@ //! Package discovery and use leases share the same short critical section as publish/remove. +use super::model_configuration::PackageArtifact; use crate::session::PluginCatalog; use ora_node_protocol::{PluginFailureCode, PluginId, PluginVersion}; use ora_plugin_manager::{ @@ -57,7 +58,7 @@ impl DirectoryPluginCatalog { } /// Returns the location derived only from plugin-domain validated segments. - fn path(&self, id: &ora_domain::PluginId, version: &semver::Version) -> PathBuf { + pub(super) fn path(&self, id: &ora_domain::PluginId, version: &semver::Version) -> PathBuf { installed_root(&self.home) .join(id.namespace()) .join(id.name()) @@ -70,6 +71,7 @@ impl DirectoryPluginCatalog { id: &ora_domain::PluginId, version: &semver::Version, declaration: Option<&ExpectedAgentDeclaration>, + artifact: &PackageArtifact<'_>, ) -> Result { let uses = self.uses(); let path = self.path(id, version); @@ -82,7 +84,11 @@ impl DirectoryPluginCatalog { verify_agent_declaration(&path, expected) .map_err(|_| PluginFailureCode::InvalidPackage)?; } - return Ok(true); + // A declared package installed without a receipt (or from other bytes) is + // reinstalled, so the configured session that follows can prove its artifact. + if declaration.is_none() || self.package_recorded(id, version, artifact) { + return Ok(true); + } } if uses.contains_key(&id.canonical()) { return Err(PluginFailureCode::PluginInUse); @@ -96,11 +102,14 @@ impl DirectoryPluginCatalog { id: &ora_domain::PluginId, version: &semver::Version, prepared: PreparedPlugin, + artifact: &PackageArtifact<'_>, ) -> Result<(), PluginFailureCode> { let uses = self.uses(); if uses.contains_key(&id.canonical()) { return Err(PluginFailureCode::PluginInUse); } + self.clear_package_record(id, version) + .map_err(|_| PluginFailureCode::InstallFailed)?; let destination = prepared.commit(&self.home).map_err(super::failure)?; // Retire only version directories belonging to this plugin; its data/config live elsewhere. let parent = destination @@ -116,7 +125,8 @@ impl DirectoryPluginCatalog { .map_err(|_| PluginFailureCode::InstallFailed)?; } } - Ok(()) + self.record_package(id, version, artifact) + .map_err(|_| PluginFailureCode::InstallFailed) } /// Removes the requested package identity without following a link or deleting plugin data. @@ -163,6 +173,14 @@ impl PluginCatalog for DirectoryPluginCatalog { inspect_planned_package(&path, &id, &version).ok()?; Some(path) } + + fn check_configuration( + &self, + configuration: &ora_node_protocol::FrozenAgentConfiguration, + ) -> Result<(), crate::session::InstalledConfigurationError> { + let _uses = self.uses(); + self.check_installed_configuration(configuration) + } } /// Wire identities are opaque; only the plugin domain may admit them as directory segments. diff --git a/apps/ora-node/src/plugins/model_configuration.rs b/apps/ora-node/src/plugins/model_configuration.rs new file mode 100644 index 000000000..cc2f81590 --- /dev/null +++ b/apps/ora-node/src/plugins/model_configuration.rs @@ -0,0 +1,261 @@ +//! Management-owned package receipts bind a configured session to verified archive bytes. +//! +//! Cloud freezes a package digest, a declaration fingerprint and Settings at admission. Between +//! admission and start the same version directory can be republished, and a frozen configuration +//! can belong to another declaration; neither is visible to a capability check. Before any model +//! authority is handed out, the session re-proves all of it against what is installed. + +use super::DirectoryPluginCatalog; +use crate::session::InstalledConfigurationError; +use ora_node_protocol::{FrozenAgentConfiguration, ModelProtocol}; +use std::io::Write; + +/// The downloaded artifact's verified identity, selected by the installer before any reuse. +pub(super) struct PackageArtifact<'a> { + pub(super) sha256: &'a str, + pub(super) target: &'a str, +} + +impl DirectoryPluginCatalog { + /// Receipts live outside package content, so an archive cannot supply its own proof. + fn receipt_path( + &self, + id: &ora_domain::PluginId, + version: &semver::Version, + ) -> std::path::PathBuf { + self.home + .join("plugins") + .join(".node-receipts") + .join(id.namespace()) + .join(id.name()) + .join(format!("{version}.json")) + } + + /// Retires proof before package mutation, so a failed new receipt cannot certify new bytes + /// as old. + pub(super) fn clear_package_record( + &self, + id: &ora_domain::PluginId, + version: &semver::Version, + ) -> std::io::Result<()> { + let path = self.receipt_path(id, version); + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("invalid receipt path"))?; + match ora_utils::path::check_directory_without_symlinks(parent) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + } + match std::fs::symlink_metadata(&path) { + Ok(_) => std::fs::remove_file(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } + + /// Called only after digest-verified archive publication, under the catalog's use lock. + pub(super) fn record_package( + &self, + id: &ora_domain::PluginId, + version: &semver::Version, + artifact: &PackageArtifact<'_>, + ) -> std::io::Result<()> { + let path = self.receipt_path(id, version); + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("invalid receipt path"))?; + ora_utils::path::create_directories_without_symlinks(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.write_all(&serde_json::to_vec(&receipt(artifact))?)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) + } + + /// Absence is unproven legacy state, never permission to adopt the current claimed digest. + pub(super) fn package_recorded( + &self, + id: &ora_domain::PluginId, + version: &semver::Version, + artifact: &PackageArtifact<'_>, + ) -> bool { + let path = self.receipt_path(id, version); + if path + .parent() + .is_none_or(|parent| ora_utils::path::check_directory_without_symlinks(parent).is_err()) + { + return false; + } + if !std::fs::symlink_metadata(&path) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() < 4096) + { + return false; + } + let Ok(bytes) = std::fs::read(path) else { + return false; + }; + serde_json::from_slice::(&bytes) + .is_ok_and(|value| value == receipt(artifact)) + } + + /// Re-proves the frozen package, declaration and Settings while the session holds its lease, + /// and that the declaration offers the frozen protocol. + pub(super) fn check_installed_configuration( + &self, + configuration: &FrozenAgentConfiguration, + ) -> Result<(), InstalledConfigurationError> { + use InstalledConfigurationError::{Invalid, ProtocolMismatch}; + let (id, version) = + super::catalog::identity(&configuration.plugin_id, &configuration.plugin_version) + .map_err(|_| Invalid)?; + let target = ora_plugin_registry::current_host_target().ok_or(Invalid)?; + let artifact = PackageArtifact { + sha256: configuration.package_sha256.as_str(), + target: target.as_str(), + }; + if target.as_str() != configuration.target + || !self.package_recorded(&id, &version, &artifact) + { + return Err(Invalid); + } + let declaration = ora_plugin_manager::read_agent_declaration(&self.path(&id, &version)) + .map_err(|_| Invalid)?; + let settings = serde_json::from_str::>( + &configuration.settings_json, + ) + .map_err(|_| Invalid)?; + if declaration.fingerprint != configuration.declaration_fingerprint.as_str() + || declaration.schema_version != configuration.schema_version + || declaration.model_access.contract_version != configuration.adapter_version + || !ora_plugin_config::validate_agent_settings(&declaration, &settings) + { + return Err(Invalid); + } + let declared = declaration + .model_access + .protocols + .iter() + .any(|protocol| match protocol { + ora_plugin_config::AgentModelProtocol::OpenaiCompletions => { + configuration.protocol == ModelProtocol::OpenaiCompletions + } + ora_plugin_config::AgentModelProtocol::AnthropicMessages => { + configuration.protocol == ModelProtocol::AnthropicMessages + } + }); + if !declared { + return Err(ProtocolMismatch); + } + Ok(()) + } +} + +/// The exact receipt content; any other shape proves nothing. +fn receipt(artifact: &PackageArtifact<'_>) -> serde_json::Value { + serde_json::json!({ "sha256": artifact.sha256, "target": artifact.target }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ora_node_protocol::{BoundModel, PluginId, PluginVersion, Sha256Digest}; + use pretty_assertions::assert_eq; + + const DECLARATION: &[u8] = br#"{"schemaVersion":2,"agent":{"modelAccess":{"contractVersion":1,"protocols":["anthropic-messages"],"modelCardinality":"single"}},"settings":{"region":{"type":"string","title":"Region","description":"Region","required":true}}}"#; + + /// Archive proof, exact declaration, Settings and protocol are all required, even for a + /// third-party plugin id; replacing the package after install withdraws the proof. + #[test] + fn installed_configuration_requires_receipt_declaration_and_settings() { + let root = tempfile::tempdir().expect("Node home"); + let catalog = DirectoryPluginCatalog::new(root.path().to_path_buf()); + let id = ora_domain::PluginId::parse("vendor/minimal").expect("plugin id"); + let version = semver::Version::new(1, 0, 0); + let Some(target) = ora_plugin_registry::current_host_target() else { + return; + }; + let assets = catalog.path(&id, &version).join("assets"); + std::fs::create_dir_all(&assets).expect("package"); + std::fs::write(assets.join("config.json"), DECLARATION).expect("declaration"); + let fingerprint = ora_utils::hash::sha256_hex(DECLARATION); + let mut configuration = FrozenAgentConfiguration { + plugin_id: PluginId::new(id.canonical()), + plugin_version: PluginVersion::new(version.to_string()), + target: target.as_str().into(), + package_sha256: Sha256Digest::new("a".repeat(64)), + schema_version: 2, + declaration_fingerprint: Sha256Digest::new(fingerprint.clone()), + adapter_version: 1, + configuration_revision: 1, + settings_json: r#"{"region":"eu"}"#.into(), + protocol: ModelProtocol::AnthropicMessages, + model: BoundModel { + id: "vendor/model".into(), + name: "Model".into(), + context_window: 128_000, + max_tokens: 8192, + }, + }; + let invalid = Err(InstalledConfigurationError::Invalid); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + + let digest = "a".repeat(64); + let artifact = PackageArtifact { + sha256: &digest, + target: target.as_str(), + }; + catalog + .record_package(&id, &version, &artifact) + .expect("verified receipt"); + assert_eq!( + catalog.check_installed_configuration(&configuration), + Ok(()) + ); + + configuration.protocol = ModelProtocol::OpenaiCompletions; + assert_eq!( + catalog.check_installed_configuration(&configuration), + Err(InstalledConfigurationError::ProtocolMismatch) + ); + configuration.protocol = ModelProtocol::AnthropicMessages; + + configuration.settings_json = r#"{"region":"eu","unexpected":true}"#.into(); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + configuration.settings_json = "{}".into(); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + configuration.settings_json = r#"{"region":"eu"}"#.into(); + + configuration.declaration_fingerprint = Sha256Digest::new("c".repeat(64)); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + configuration.declaration_fingerprint = Sha256Digest::new(fingerprint); + + configuration.package_sha256 = Sha256Digest::new("b".repeat(64)); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + configuration.package_sha256 = Sha256Digest::new(digest.clone()); + + // Republishing clears the old proof before the package bytes change. + catalog + .clear_package_record(&id, &version) + .expect("retired receipt"); + assert_eq!( + catalog.check_installed_configuration(&configuration), + invalid + ); + } +} diff --git a/apps/ora-node/src/session.rs b/apps/ora-node/src/session.rs index cb819f497..5fcad4988 100644 --- a/apps/ora-node/src/session.rs +++ b/apps/ora-node/src/session.rs @@ -20,9 +20,9 @@ mod workload; pub(crate) use model::seal_unstarted_history; pub use ports::{ - CheckoutResolver, CommandSettlement, HistoryUnavailable, PluginCatalog, PriorRevisionRestore, - QueuedCommand, RestoreCancel, RestoreFailure, RestoreRequest, Restored, SessionCommand, - SessionHost, SessionLedger, + CheckoutResolver, CommandSettlement, HistoryUnavailable, InstalledConfigurationError, + PluginCatalog, PriorRevisionRestore, QueuedCommand, RestoreCancel, RestoreFailure, + RestoreRequest, Restored, SessionCommand, SessionHost, SessionLedger, }; pub use workload::SessionWorkload; pub(crate) use workload::purge_workload_directory; diff --git a/apps/ora-node/src/session/driver.rs b/apps/ora-node/src/session/driver.rs index 0827e1010..0b8822a76 100644 --- a/apps/ora-node/src/session/driver.rs +++ b/apps/ora-node/src/session/driver.rs @@ -129,6 +129,13 @@ where let stopping = shared.stopping.subscribe(); let restored = resume::restore_prior( &shared.restorer, + // Admission checked a declaration Cloud holds; under the lease the Node proves the installed + // bytes, declaration and Settings are that same one before any model authority exists. + if let Some(configuration) = &spec.agent_configuration + && let Err(error) = shared.catalog.check_configuration(configuration) + { + return SessionEnd::agent_failed(error.code()); + } &shared.ledger, operation, execution, diff --git a/apps/ora-node/src/session/model/tests.rs b/apps/ora-node/src/session/model/tests.rs index 71d9680d8..8025c8edb 100644 --- a/apps/ora-node/src/session/model/tests.rs +++ b/apps/ora-node/src/session/model/tests.rs @@ -56,6 +56,12 @@ impl crate::PluginCatalog for CheckoutPath { ) -> Option { Some(self.0.clone()) } + fn check_configuration( + &self, + _configuration: &ora_node_protocol::FrozenAgentConfiguration, + ) -> Result<(), crate::InstalledConfigurationError> { + Ok(()) + } } #[derive(Default)] diff --git a/apps/ora-node/src/session/ports.rs b/apps/ora-node/src/session/ports.rs index f1c835eaa..7eeecd32a 100644 --- a/apps/ora-node/src/session/ports.rs +++ b/apps/ora-node/src/session/ports.rs @@ -120,6 +120,33 @@ pub trait PluginCatalog: Send + Sync + 'static { /// Returns the package directory of exactly this installed version, if present. fn installed(&self, plugin_id: &PluginId, version: &PluginVersion) -> Option; + + /// Proves the installed package is the artifact Cloud froze for a configured session, that it + /// still carries the frozen declaration, that the Settings are valid for it, and that it + /// offers the frozen protocol. Called under the session's lease, before model authority. + fn check_configuration( + &self, + configuration: &ora_node_protocol::FrozenAgentConfiguration, + ) -> Result<(), InstalledConfigurationError>; +} + +/// Why the installed package cannot run a frozen configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstalledConfigurationError { + /// The package, its declaration or the Settings are not the ones Cloud froze. + Invalid, + /// The installed declaration does not offer the protocol Cloud froze. + ProtocolMismatch, +} + +impl InstalledConfigurationError { + /// The bounded session failure code for this refusal. + pub fn code(self) -> &'static str { + match self { + Self::Invalid => "agent_model_configuration_invalid", + Self::ProtocolMismatch => "agent_model_protocol_mismatch", + } + } } /// The session history of an execution cannot be delivered yet, or at all. diff --git a/apps/ora-node/tests/agent_session.rs b/apps/ora-node/tests/agent_session.rs index af124eced..8a66693e9 100644 --- a/apps/ora-node/tests/agent_session.rs +++ b/apps/ora-node/tests/agent_session.rs @@ -770,3 +770,31 @@ async fn an_unconfirmed_adapter_never_receives_a_prompt() { .all(|pid| !process_exists(pid)) ); } + +/// A configured session whose installed package no longer proves the frozen artifact, +/// declaration or Settings ends before any grant is requested or any plugin process starts. +#[tokio::test(flavor = "multi_thread")] +async fn a_configuration_the_installed_package_cannot_prove_never_gets_a_grant() { + let fixture = Fixture::new(); + *fixture.configuration.lock().unwrap() = Err(ora_node::InstalledConfigurationError::Invalid); + let gateway = gateway::Gateway::new(&fixture.home()); + let sessions = fixture.model_sessions(gateway.config.clone()); + fixture.start_configured(&sessions, "must not be sent", r#"{"effort":"high"}"#); + let ended = fixture.ended().await; + sessions.shutdown().await; + assert_eq!( + (ended.reason, ended.detail.as_deref()), + ( + AgentSessionEndReason::AgentFailed, + Some("agent_model_configuration_invalid") + ) + ); + assert_eq!( + ( + fixture.ledger.events(), + grant_routes(&gateway), + fixture.plugin_pids() + ), + (Vec::new(), Vec::::new(), Vec::new()) + ); +} diff --git a/apps/ora-node/tests/agent_session/support.rs b/apps/ora-node/tests/agent_session/support.rs index 433e9fdd0..29bf48987 100644 --- a/apps/ora-node/tests/agent_session/support.rs +++ b/apps/ora-node/tests/agent_session/support.rs @@ -1,9 +1,10 @@ //! In-memory ledger, checkout and plugin catalog, plus a Node home with the echo agent installed. use ora_node::{ - AgentSessions, CheckoutResolver, CommandSettlement, ModelProxyConfig, PluginCatalog, - PriorRevisionRestore, QueuedCommand, RestoreCancel, RestoreFailure, RestoreRequest, Restored, - SessionCommand, SessionConfig, SessionHost, SessionLedger, SessionWorkload, + AgentSessions, CheckoutResolver, CommandSettlement, InstalledConfigurationError, + ModelProxyConfig, PluginCatalog, PriorRevisionRestore, QueuedCommand, RestoreCancel, + RestoreFailure, RestoreRequest, Restored, SessionCommand, SessionConfig, SessionHost, + SessionLedger, SessionWorkload, }; use ora_node_protocol::{ AgentSessionEnded, AgentSessionSpec, BoundModel, CommandId, ContentBlock, ExecutionId, @@ -254,6 +255,8 @@ impl Drop for CountedLease { pub struct Catalog { pub installed: HashMap<(PluginId, PluginVersion), PathBuf>, pub leases: Arc, + /// How the installed package answers a frozen configuration; accepted unless set. + pub configuration: Arc>>, } impl PluginCatalog for Catalog { @@ -269,6 +272,16 @@ impl PluginCatalog for Catalog { .get(&(plugin_id.clone(), version.clone())) .cloned() } + + fn check_configuration( + &self, + _configuration: &FrozenAgentConfiguration, + ) -> Result<(), InstalledConfigurationError> { + *self + .configuration + .lock() + .unwrap_or_else(PoisonError::into_inner) + } } /// Answers every restore with a scripted result and records the requests it saw. @@ -310,6 +323,8 @@ pub struct Fixture { pub ledger: MemoryLedger, pub leases: Arc, pub restorer: Restorer, + /// What the catalog answers when a configured session checks its installed package. + pub configuration: Arc>>, } impl Fixture { @@ -326,6 +341,7 @@ impl Fixture { hold: Arc::default(), cancelled: Arc::default(), }, + configuration: Arc::new(Mutex::new(Ok(()))), }; let package = fixture.package_root(); std::fs::create_dir_all(&package).expect("create package root"); @@ -450,6 +466,7 @@ impl Fixture { self.package_root_of(installed_version), )]), leases: Arc::clone(&self.leases), + configuration: Arc::clone(&self.configuration), }, self.restorer.clone(), ) From e180ba8cbf8bf3400cf660dc3cc534d2e1033a7e Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:10 +0000 Subject: [PATCH 10/18] fix(node): start configured sessions from an empty environment A configured session's plugin and every process the host spawns for it now clear inheritance and receive only PATH, LANG, LC_ALL, TMPDIR and the session's own variables, so provider keys, base URLs and CLI configuration in the Node's environment never reach the adapter or its CLI (ADR D4). Sessions without a frozen configuration keep the existing inherited environment. The echo fixture reports whether it still sees the test runner's own variables. --- apps/ora-node/fixtures/echo-agent/agent.rs | 8 ++-- apps/ora-node/src/session.rs | 1 + apps/ora-node/src/session/driver.rs | 4 +- apps/ora-node/src/session/host.rs | 7 +-- apps/ora-node/src/session/process.rs | 53 ++++++++++++++++++++++ apps/ora-node/src/session/workload.rs | 12 +++-- apps/ora-node/tests/agent_session.rs | 7 ++- 7 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 apps/ora-node/src/session/process.rs diff --git a/apps/ora-node/fixtures/echo-agent/agent.rs b/apps/ora-node/fixtures/echo-agent/agent.rs index 4d4bc7f5d..20115695f 100644 --- a/apps/ora-node/fixtures/echo-agent/agent.rs +++ b/apps/ora-node/fixtures/echo-agent/agent.rs @@ -266,14 +266,16 @@ impl EchoAgent { return respond(output, id, &PromptResponse::new(StopReason::EndTurn)).await; } let answer = if text.contains("[model]") { - // Reports what the adapter received, and whether the token also leaked into the - // environment every process the plugin spawns would inherit. + // Reports what the adapter received, whether the token also leaked into the + // environment every process the plugin spawns would inherit, and whether the Node's + // own environment (here the test runner's `CARGO*` variables) reached it at all. match &self.model { Some(model) => format!( - "model={} proxy={} token_in_environment={}", + "model={} proxy={} token_in_environment={} host_environment={}", model.configuration.model.id, model.proxy_base_url, std::env::vars().any(|(_, value)| value.contains(&model.token)), + std::env::vars().any(|(name, _)| name.starts_with("CARGO")), ), None => "model=none".to_string(), } diff --git a/apps/ora-node/src/session.rs b/apps/ora-node/src/session.rs index 5fcad4988..2102bb05c 100644 --- a/apps/ora-node/src/session.rs +++ b/apps/ora-node/src/session.rs @@ -13,6 +13,7 @@ mod ledger; mod model; mod ports; mod preview; +mod process; mod queue; mod resume; mod thread; diff --git a/apps/ora-node/src/session/driver.rs b/apps/ora-node/src/session/driver.rs index 0b8822a76..82c6369a3 100644 --- a/apps/ora-node/src/session/driver.rs +++ b/apps/ora-node/src/session/driver.rs @@ -254,7 +254,9 @@ where None => None, }; let taps = GenerationTaps::default(); - let launcher = placement.launcher(&spec.git_identity, &package_root, environment); + let isolation = + super::process::ModelProcessIsolation::for_session(spec.agent_configuration.is_some()); + let launcher = placement.launcher(&spec.git_identity, &package_root, environment, isolation); let lifecycle = match open_lifecycle(&shared.config, launcher, &taps) { Ok(lifecycle) => lifecycle, Err(error) => { diff --git a/apps/ora-node/src/session/host.rs b/apps/ora-node/src/session/host.rs index 4b4d23650..9606350a1 100644 --- a/apps/ora-node/src/session/host.rs +++ b/apps/ora-node/src/session/host.rs @@ -60,9 +60,10 @@ impl AgentRuntimeHost for NodeRuntimeHost { /// identity always, and in a separate workload placement the session's own home. /// /// Set on the plugin process, so a process the plugin spawns directly inherits it, and on every -/// process the host spawns for the plugin, which inherits the host's environment instead. Nothing -/// is written to a Git configuration. The rest of the host environment (model credentials, proxy -/// settings) still reaches the agent: these variables are layered on top of it, never instead. +/// process the host spawns for the plugin. Nothing is written to a Git configuration. A session +/// without a frozen configuration layers these variables on top of the host environment; a +/// configured session starts from an empty environment instead, so nothing of the Node's own +/// (model credentials, provider base URLs, CLI configuration) reaches its adapter or CLI. #[derive(Clone)] pub(super) struct SessionEnvironment { variables: BTreeMap, diff --git a/apps/ora-node/src/session/process.rs b/apps/ora-node/src/session/process.rs new file mode 100644 index 000000000..eeb2dc621 --- /dev/null +++ b/apps/ora-node/src/session/process.rs @@ -0,0 +1,53 @@ +//! A declared model adapter cannot inherit management-host credentials or routing. +//! +//! The plugin and every process the host spawns for it share one spawner, so clearing inheritance +//! there keeps provider keys, base URLs and CLI configuration variables of the Node's own +//! environment away from the adapter and its CLI alike (ADR D4). + +use ora_process::{ProcessEnvironment, ProcessIdentity, TokioProcessSpawner}; +use std::collections::BTreeMap; + +/// Variables a process needs to run at all and that grant no authority, copied into an +/// isolated session's explicit environment. +const PROCESS_NECESSITIES: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TMPDIR"]; + +/// Whether a session's processes inherit the Node's environment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ModelProcessIsolation { + /// Sessions without a frozen configuration keep the existing inherited environment. + Ambient, + /// A configured session sees only what the session supplies explicitly. + Isolated, +} + +impl ModelProcessIsolation { + /// Isolates exactly the sessions that run a declared adapter. + pub(super) fn for_session(configured: bool) -> Self { + if configured { + Self::Isolated + } else { + Self::Ambient + } + } + + /// The spawner for the plugin and every host-managed child of this session. + pub(super) fn spawner(self, identity: ProcessIdentity) -> TokioProcessSpawner { + let environment = match self { + Self::Ambient => ProcessEnvironment::Inherit, + Self::Isolated => ProcessEnvironment::Empty, + }; + TokioProcessSpawner::running_as(identity).with_environment(environment) + } + + /// Copies only non-authoritative process necessities into the explicit environment; values + /// the session already set win. + pub(super) fn extend_environment(self, variables: &mut BTreeMap) { + if self == Self::Isolated { + for name in PROCESS_NECESSITIES { + if let Ok(value) = std::env::var(name) { + variables.entry(name.to_owned()).or_insert(value); + } + } + } + } +} diff --git a/apps/ora-node/src/session/workload.rs b/apps/ora-node/src/session/workload.rs index 90b900c2f..3e446463f 100644 --- a/apps/ora-node/src/session/workload.rs +++ b/apps/ora-node/src/session/workload.rs @@ -16,13 +16,14 @@ //! by name but cannot list or replace them. It lives exactly as long as the session's plugin. use super::host::{SessionEnvironment, SessionLauncher}; +use super::process::ModelProcessIsolation; use ora_logging::ora_warn; use ora_node_protocol::{ExecutionId, GitIdentity}; use ora_plugin_lifecycle::{ DenoPluginRuntimeLauncher, LaunchedRuntime, PluginLaunchRequest, PluginLogSetup, PluginRuntimeFailure, PluginRuntimeLauncher, PluginRuntimeTimeouts, }; -use ora_process::{ProcessIdentity, TokioProcessSpawner}; +use ora_process::ProcessIdentity; use std::fs; use std::future::Future; use std::io; @@ -98,15 +99,18 @@ impl SessionPlacement { &self, git_identity: &GitIdentity, package_root: &Path, - runtime: std::collections::BTreeMap, + mut runtime: std::collections::BTreeMap, + isolation: ModelProcessIsolation, ) -> SessionLauncher { + isolation.extend_environment(&mut runtime); let environment = SessionEnvironment::new(git_identity, runtime); match self { Self::Shared => PackageViewLauncher { inner: DenoPluginRuntimeLauncher::with_environment_provider( PluginRuntimeTimeouts::default(), environment, - ), + ) + .with_spawner(isolation.spawner(ProcessIdentity::Inherit)), location: PackageLocation::Installed, }, Self::Separate { @@ -117,7 +121,7 @@ impl SessionPlacement { PluginRuntimeTimeouts::default(), environment.with_home(&directory.home), ) - .with_spawner(TokioProcessSpawner::running_as(*identity)), + .with_spawner(isolation.spawner(*identity)), location: PackageLocation::View { installed: package_root.to_path_buf(), view: directory.package.clone(), diff --git a/apps/ora-node/tests/agent_session.rs b/apps/ora-node/tests/agent_session.rs index 8a66693e9..dc1c84155 100644 --- a/apps/ora-node/tests/agent_session.rs +++ b/apps/ora-node/tests/agent_session.rs @@ -684,9 +684,12 @@ fn grant_routes(gateway: &gateway::Gateway) -> Vec { /// A configured session hands its grant to the adapter in the startup context alone: the adapter /// answers with the frozen model and the platform proxy, the token never reaches its environment, -/// history or Thread, and ending the session revokes the grant. +/// history or Thread, none of the Node's own environment is inherited, and ending the session +/// revokes the grant. #[tokio::test(flavor = "multi_thread")] async fn a_configured_adapter_receives_its_grant_only_in_the_startup_context() { + // The probe is the test runner's own environment, which the Node process inherits. + assert!(std::env::vars().any(|(name, _)| name.starts_with("CARGO"))); let fixture = Fixture::new(); let gateway = gateway::Gateway::new(&fixture.home()); let sessions = fixture.model_sessions(gateway.config.clone()); @@ -713,7 +716,7 @@ async fn a_configured_adapter_receives_its_grant_only_in_the_startup_context() { (answer, ended.reason), ( format!( - "model=team/model proxy=https://localhost:{port}/runtime/openai/v1 token_in_environment=false" + "model=team/model proxy=https://localhost:{port}/runtime/openai/v1 token_in_environment=false host_environment=false" ), AgentSessionEndReason::UserEnded, ) From e235258472b035f619518113b408ff88b3d840d9 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:16 +0000 Subject: [PATCH 11/18] fix(node): keep the legacy model route to the OpenCode package A model binding without a frozen configuration used to fall back to the legacy route for any plugin, injecting the OpenCode configuration and the live grant token. Only official/ora-space.opencode may take that route now; any other plugin ends with agent_model_configuration_required before a grant is requested or a process starts. --- apps/ora-node/src/session/driver.rs | 29 +++++++++------ apps/ora-node/src/session/model/tests.rs | 4 +-- apps/ora-node/tests/agent_session.rs | 45 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/ora-node/src/session/driver.rs b/apps/ora-node/src/session/driver.rs index 82c6369a3..34514e192 100644 --- a/apps/ora-node/src/session/driver.rs +++ b/apps/ora-node/src/session/driver.rs @@ -32,6 +32,10 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::Notify; +/// The one package the Node may still configure for a model binding without a frozen +/// configuration: the OpenCode build that predates plugin-owned adapters. +const LEGACY_OPENCODE_PLUGIN: &str = "official/ora-space.opencode"; + /// Runs one session execution to its end and writes the terminal result. /// /// Commands still queued when the session ends are settled as discarded before the live entry @@ -125,10 +129,6 @@ where let Ok(plugin_id) = PluginId::parse(spec.agent_plugin_id.as_str()) else { return SessionEnd::agent_failed("agent_plugin_unavailable"); }; - // The checkout still has the clone's owner here; the hand-back below covers what Git wrote. - let stopping = shared.stopping.subscribe(); - let restored = resume::restore_prior( - &shared.restorer, // Admission checked a declaration Cloud holds; under the lease the Node proves the installed // bytes, declaration and Settings are that same one before any model authority exists. if let Some(configuration) = &spec.agent_configuration @@ -136,6 +136,10 @@ where { return SessionEnd::agent_failed(error.code()); } + // The checkout still has the clone's owner here; the hand-back below covers what Git wrote. + let stopping = shared.stopping.subscribe(); + let restored = resume::restore_prior( + &shared.restorer, &shared.ledger, operation, execution, @@ -177,12 +181,17 @@ where let Some(config) = shared.config.model_proxy.clone() else { return SessionEnd::agent_failed("model_proxy_unavailable"); }; - // A frozen configuration hands the grant to the plugin's adapter; without one the - // package is a legacy OpenCode build whose configuration the Node still writes. - let route = spec - .agent_configuration - .as_ref() - .map_or(ModelRoute::LegacyOpenCode, ModelRoute::Adapter); + // A frozen configuration hands the grant to the plugin's adapter; without one only + // the legacy OpenCode package, whose configuration the Node still writes, may run. + // Any other plugin would receive a live token it never declared, so it is refused + // before a grant exists. + let route = match &spec.agent_configuration { + Some(configuration) => ModelRoute::Adapter(configuration), + None if spec.agent_plugin_id.as_str() == LEGACY_OPENCODE_PLUGIN => { + ModelRoute::LegacyOpenCode + } + None => return SessionEnd::agent_failed("agent_model_configuration_required"), + }; let home = &shared.config.home_directory; match ModelAccess::open(config, binding, execution, home, route).await { Ok(access) => Some(access), diff --git a/apps/ora-node/src/session/model/tests.rs b/apps/ora-node/src/session/model/tests.rs index 8025c8edb..ffab5eb2d 100644 --- a/apps/ora-node/src/session/model/tests.rs +++ b/apps/ora-node/src/session/model/tests.rs @@ -264,7 +264,7 @@ async fn production_driver_reconciles_early_user_end_without_launching_a_plugin( AgentSessionSpec { agent_configuration: None, node_id: node.node_id.clone(), - agent_plugin_id: PluginId::new("official/ora-space.echo"), + agent_plugin_id: PluginId::new("official/ora-space.opencode"), agent_plugin_version: PluginVersion::new("1.0.0"), checkout_execution_id: ExecutionId::new("clone-1"), model_binding_id: Some(ModelBindingId::new("binding-1")), @@ -439,13 +439,13 @@ fn production_driver_defers_unknown_restore_without_releasing_terminal_history() execution.clone(), AgentSessionSpec { workspace_directory: Vec::new(), + agent_configuration: None, node_id: node.node_id.clone(), agent_plugin_id: PluginId::new("official/ora-space.echo"), agent_plugin_version: PluginVersion::new("1.0.0"), checkout_execution_id: ExecutionId::new("clone"), model_binding_id: None, git_identity: GitIdentity { - agent_configuration: None, name: "Fixture".into(), email: "fixture@example.test".into(), }, diff --git a/apps/ora-node/tests/agent_session.rs b/apps/ora-node/tests/agent_session.rs index dc1c84155..ffc5f8ec5 100644 --- a/apps/ora-node/tests/agent_session.rs +++ b/apps/ora-node/tests/agent_session.rs @@ -801,3 +801,48 @@ async fn a_configuration_the_installed_package_cannot_prove_never_gets_a_grant() (Vec::new(), Vec::::new(), Vec::new()) ); } + +/// Only the legacy OpenCode package may take a model binding without a frozen configuration; any +/// other plugin is refused before a grant exists, so it never sees a live token. +#[tokio::test(flavor = "multi_thread")] +async fn an_undeclared_plugin_never_takes_the_legacy_model_route() { + use ora_node_protocol::{ + AgentSessionSpec, ExecutionId, ModelBindingId, OperationId, PluginId, PluginVersion, + }; + let fixture = Fixture::new(); + let gateway = gateway::Gateway::new(&fixture.home()); + let sessions = fixture.model_sessions(gateway.config.clone()); + sessions.start( + OperationId::new(OPERATION), + ExecutionId::new(EXECUTION), + AgentSessionSpec { + agent_configuration: None, + node_id: fixture.node().node_id, + agent_plugin_id: PluginId::new(PLUGIN_ID), + agent_plugin_version: PluginVersion::new(PLUGIN_VERSION), + checkout_execution_id: ExecutionId::new(CHECKOUT_EXECUTION), + model_binding_id: Some(ModelBindingId::new("binding-1")), + prior_revision: None, + workspace_directory: vec![], + git_identity: identity(), + initial_turn: turn("turn-1", "must not be sent"), + }, + ); + let ended = fixture.ended().await; + sessions.shutdown().await; + assert_eq!( + (ended.reason, ended.detail.as_deref()), + ( + AgentSessionEndReason::AgentFailed, + Some("agent_model_configuration_required") + ) + ); + assert_eq!( + ( + fixture.ledger.events(), + grant_routes(&gateway), + fixture.plugin_pids() + ), + (Vec::new(), Vec::::new(), Vec::new()) + ); +} From ae8483825222f58e2bdc331675e49b602d9e963d Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:16 +0000 Subject: [PATCH 12/18] fix(agent-runtime): keep reserved start codes on a configured start A configured agent/start failure still drops the plugin's message, but -32001 and -32002 again map to AgentNotInstalled and AgentUnusable, so a host without the CLI keeps retrying quietly instead of tripping the restart circuit. --- .../agent-runtime/src/plugin_agent/control.rs | 21 +++++++++++++++-- .../agent-runtime/src/plugin_agent/tests.rs | 23 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/agent-runtime/src/plugin_agent/control.rs b/crates/agent-runtime/src/plugin_agent/control.rs index 3b40595ae..58cafa879 100644 --- a/crates/agent-runtime/src/plugin_agent/control.rs +++ b/crates/agent-runtime/src/plugin_agent/control.rs @@ -53,6 +53,21 @@ impl From for PluginAgentError { } } +/// Classifies a configured start failure by its code alone; the plugin's message is dropped. +pub(super) fn sensitive_start_error(error: &PluginRuntimeError) -> PluginAgentError { + match error { + PluginRuntimeError::Remote { + code: AGENT_NOT_INSTALLED_CODE, + .. + } => PluginAgentError::AgentNotInstalled, + PluginRuntimeError::Remote { + code: AGENT_UNUSABLE_CODE, + .. + } => PluginAgentError::AgentUnusable("agent_model_adapter_failed".into()), + _ => PluginAgentError::Failed("agent_model_adapter_failed".into()), + } +} + /// Describes one model an agent plugin offers before any session exists. /// /// This is deliberately separate from ACP session config options, which only exist after @@ -129,13 +144,15 @@ pub(super) async fn start_agent( model_context, }) .map_err(|error| PluginAgentError::Failed(error.to_string()))?; - // Remote errors may contain arbitrary plugin text. Sensitive startup must never echo it. + // Remote errors may contain arbitrary plugin text. Sensitive startup must never echo it, but + // it keeps the error code, so a missing or unusable CLI stays an unavailable agent that is + // retried quietly rather than a failure that trips the restart circuit. let result = runtime .invoke(AGENT_START_METHOD, params) .await .map_err(|error| { if expected.is_some() { - PluginAgentError::Failed("agent_model_adapter_failed".into()) + sensitive_start_error(&error) } else { error.into() } diff --git a/crates/agent-runtime/src/plugin_agent/tests.rs b/crates/agent-runtime/src/plugin_agent/tests.rs index fbbca7e3d..4a7703076 100644 --- a/crates/agent-runtime/src/plugin_agent/tests.rs +++ b/crates/agent-runtime/src/plugin_agent/tests.rs @@ -9,7 +9,7 @@ use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::mpsc; -use super::control::{PluginAgentError, verify_agent_contract}; +use super::control::{PluginAgentError, sensitive_start_error, verify_agent_contract}; use super::effect::{AgentEffectError, registered_consumer_declaration}; use super::inbound::{discard_frames_before_start, spawn_frame_forwarding}; @@ -127,6 +127,27 @@ fn classifies_the_two_reserved_agent_startup_codes_apart_from_every_other_failur ); } +/// A configured start keeps the two reserved codes apart, so a missing CLI stays a quiet +/// retry, but never carries the plugin's own message, which may echo the model context. +#[test] +fn classifies_configured_startup_codes_without_the_plugin_message() { + let classify = |code: i64| { + sensitive_start_error(&PluginRuntimeError::Remote { + code, + message: "token=synthetic-temporary-token".to_string(), + }) + }; + + assert_eq!( + [classify(-32001), classify(-32002), classify(-32000)], + [ + PluginAgentError::AgentNotInstalled, + PluginAgentError::AgentUnusable("agent_model_adapter_failed".to_string()), + PluginAgentError::Failed("agent_model_adapter_failed".to_string()), + ] + ); +} + /// Builds one plugin registration declaring a single Skill Resource in the given format. fn registration_declaring(format: &str) -> PluginRegistration { let mut registration = complete_registration(); From f54d0edecb46e34dd6b86bfb24aa25218b6e79f4 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:16 +0000 Subject: [PATCH 13/18] fix(node): align the adapter capability with Cloud's bounds Accept frozen Settings up to the 256 KiB Cloud stores and the plugin SDK accepts, and advertise the adapter capability only on the two Linux targets Cloud admits, so another target never gets its whole registration refused. --- apps/ora-node/src/session.rs | 6 +++++- crates/node-protocol/src/domain/model_adapter.rs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/ora-node/src/session.rs b/apps/ora-node/src/session.rs index 2102bb05c..f1c649152 100644 --- a/apps/ora-node/src/session.rs +++ b/apps/ora-node/src/session.rs @@ -150,6 +150,10 @@ where } } +/// Targets Cloud accepts in an adapter capability; advertising any other would make Cloud refuse +/// the whole registration instead of only this capability. +const ADAPTER_TARGETS: [&str; 2] = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]; + impl AgentSessions { /// Gives every session an `ora-git` socket served by `broker`. Composition calls it before /// any session starts, while it still holds the only handle. @@ -178,7 +182,7 @@ impl AgentSessions { /// outside the supported Linux targets keeps only the legacy OpenCode route. pub(crate) fn model_adapter(&self) -> Option { let target = ora_plugin_registry::current_host_target()?; - (self.model_capable() && target.as_str().ends_with("-unknown-linux-gnu")).then(|| { + (self.model_capable() && ADAPTER_TARGETS.contains(&target.as_str())).then(|| { ora_node_protocol::ModelAdapterCapability { contract_version: ora_node_protocol::MODEL_ADAPTER_CONTRACT_VERSION, target: target.as_str().to_string(), diff --git a/crates/node-protocol/src/domain/model_adapter.rs b/crates/node-protocol/src/domain/model_adapter.rs index 05aae8420..1354a3386 100644 --- a/crates/node-protocol/src/domain/model_adapter.rs +++ b/crates/node-protocol/src/domain/model_adapter.rs @@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize}; /// The only adapter contract this protocol version can describe. pub const MODEL_ADAPTER_CONTRACT_VERSION: u32 = 1; -/// Largest frozen Settings document; declarations bound their fields far below this. -pub const MAX_AGENT_SETTINGS_BYTES: usize = 64 * 1024; +/// Largest frozen Settings document: the bound Cloud stores and the plugin SDK accepts, so a +/// configuration Cloud saved is never refused only by the Node on the way to the adapter. +pub const MAX_AGENT_SETTINGS_BYTES: usize = 256 * 1024; /// Model wire protocols a platform proxy serves to an adapter. #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] From c39ddc4a80b4b74a4074d908d45fc62ba575fd92 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:16 +0000 Subject: [PATCH 14/18] docs(plugins): describe the configured-session start checks --- docs/plugins/agent-configuration.md | 13 +++++++++++++ docs/plugins/agent-configuration.zh.md | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/plugins/agent-configuration.md b/docs/plugins/agent-configuration.md index 5db9f4025..02c80fc23 100644 --- a/docs/plugins/agent-configuration.md +++ b/docs/plugins/agent-configuration.md @@ -88,6 +88,19 @@ compile to the associated schema version and exact-byte digest, or the installer in staging. An Agent package may carry a declaration without an association as inert package metadata; the declaration alone grants no personal configuration or model access. +After publishing a package, the Node keeps a receipt outside the package content naming the archive +digest and target it verified; republishing the same version clears the old receipt first. A session +with a frozen configuration checks again while it holds the package lease and before it requests +model access: the receipt matches the frozen package digest and target; the installed declaration's +fingerprint, schema and adapter contract version match the frozen values; the frozen Settings +validate against that declaration; and the declaration offers the frozen protocol. Otherwise the +session ends with `agent_model_configuration_invalid` or `agent_model_protocol_mismatch` and no +plugin process starts. Such a session's plugin, and every process the host spawns for it, starts from +an empty environment with only `PATH`, `LANG`, `LC_ALL`, `TMPDIR` and the session's own variables, +so credentials and CLI configuration in the Node's environment never reach it. A model binding +without a frozen configuration is accepted only for the legacy `official/ora-space.opencode`; any +other plugin ends with `agent_model_configuration_required`. + ## Decisions - [Agent configuration declarations](https://github.com/ora-space/specs/blob/main/decisions/desktop/plugin/manifest/20261017-agent-configuration-declarations.md) diff --git a/docs/plugins/agent-configuration.zh.md b/docs/plugins/agent-configuration.zh.md index 633d76b9b..5e5a43c5e 100644 --- a/docs/plugins/agent-configuration.zh.md +++ b/docs/plugins/agent-configuration.zh.md @@ -73,6 +73,15 @@ section 内的未知键都会被拒绝。副本路径由 `/` 分隔的 ASCII 段 包内 `assets/config.json` 必须编译到关联的 schema 版本且原始字节摘要一致,否则安装器在 staging 阶段拒绝包。Agent 包可以携带没有发行关联的惰性声明元数据;声明本身不授予个人配置或模型访问权。 +Node 发布包后,在包内容之外记一份收据,写明本机核对过的归档摘要和 target;重新发布同一版本前 +先清掉旧收据。带冻结配置的会话启动时,在持有包租约、申请模型授权之前再核对一次:收据与冻结的 +包摘要和 target 一致,已安装声明的指纹、schema 和 adapter 契约版本与冻结值一致,冻结的 +Settings 按该声明校验通过,且声明提供冻结的协议。任一不满足就以 +`agent_model_configuration_invalid` 或 `agent_model_protocol_mismatch` 结束,插件进程不会启动。 +这类会话的插件及宿主为它拉起的进程从空环境启动,只补 `PATH`、`LANG`、`LC_ALL`、`TMPDIR` 和 +会话自己的变量,Node 环境里的凭据和 CLI 配置不会传进去。没有冻结配置的模型绑定只能用于旧版 +`official/ora-space.opencode`,其他插件以 `agent_model_configuration_required` 结束。 + ## 决策 - [Agent 配置声明](https://github.com/ora-space/specs/blob/main/decisions/desktop/plugin/manifest/20261017-agent-configuration-declarations.md) From c91935c47ebce6d332d5acea2d02753062673e59 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 09:48:34 +0000 Subject: [PATCH 15/18] build(controller-proto): pin the rebased Cloud contract cloud#106 was rebased onto Cloud main without changing its proto; the pin follows the new head so the commit stays fetchable. --- third_party/cloud | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/cloud b/third_party/cloud index 1be061007..132cc6a88 160000 --- a/third_party/cloud +++ b/third_party/cloud @@ -1 +1 @@ -Subproject commit 1be061007486a412893bf19b6ee7e4530fdf40f5 +Subproject commit 132cc6a88c518c267774a37cdb2d4570dfd0968e From d7b5b6b8bf3941bee794ca2ad0b25f63d875367f Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 12:23:33 +0000 Subject: [PATCH 16/18] docs(node): describe the configured-session environment and adapter fields agent-sessions still said the Node's model credentials and proxy settings reach every agent; a session with a frozen Agent configuration now starts from an empty environment. protocol now names agent_configuration on a session start and model_adapter in hello_accepted, and that an older Node rejects the field rather than ignoring it. --- docs/node/agent-sessions.md | 10 +++++++--- docs/node/agent-sessions.zh.md | 4 +++- docs/node/protocol.md | 13 +++++++++++-- docs/node/protocol.zh.md | 7 ++++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/node/agent-sessions.md b/docs/node/agent-sessions.md index 54977670f..99fb1d321 100644 --- a/docs/node/agent-sessions.md +++ b/docs/node/agent-sessions.md @@ -48,9 +48,13 @@ already in use is refused); `prompt_session_as_message` records the host's messa `messageId` of every block of the user message. The run's Git identity is exported as `GIT_AUTHOR_*`/`GIT_COMMITTER_*` on the plugin process and on -every process the host spawns for it; the Node writes no Git configuration. These variables are -layered on the inherited environment, so model credentials and proxy settings from the Node's -environment still reach the agent. +every process the host spawns for it; the Node writes no Git configuration. For a session with a +frozen Agent configuration, these variables join an otherwise empty environment that keeps only +`PATH`, `LANG`, `LC_ALL` and `TMPDIR`, so model credentials, proxy settings and CLI configuration in +the Node's environment never reach the agent (see +[Agent configuration](../plugins/agent-configuration.md)). Any other session layers them on the +inherited environment, where the Node's own model credentials and proxy settings still reach the +agent. ### Separate workload user diff --git a/docs/node/agent-sessions.zh.md b/docs/node/agent-sessions.zh.md index 78820994c..d13dac0d7 100644 --- a/docs/node/agent-sessions.zh.md +++ b/docs/node/agent-sessions.zh.md @@ -40,7 +40,9 @@ Deno 路径必须是绝对路径,超时必须为正数。有这个配置段时 `prompt_session_as_message` 把宿主的消息身份记为用户消息中每个块的 ACP `messageId`。 运行的 Git 身份以 `GIT_AUTHOR_*`/`GIT_COMMITTER_*` 导出到插件进程以及宿主为它启动的每个进程上;Node 不写任何 Git 配置。 -这些变量叠加在继承的环境之上,所以 Node 环境中的模型凭据和代理设置仍会到达 Agent。 +带冻结 Agent 配置的会话里,这些变量加在一个只保留 `PATH`、`LANG`、`LC_ALL` 和 `TMPDIR` 的空环境上,Node 环境中的 +模型凭据、代理设置和 CLI 配置都不会到达 Agent(见 [Agent 配置](../plugins/agent-configuration.zh.md))。其他会话仍叠加在 +继承的环境之上,Node 自己的模型凭据和代理设置会到达 Agent。 ### 单独的工作负载用户 diff --git a/docs/node/protocol.md b/docs/node/protocol.md index bafcbf863..0435bab90 100644 --- a/docs/node/protocol.md +++ b/docs/node/protocol.md @@ -42,7 +42,7 @@ a response to an earlier Hello and enforcing handshake order belong to the sessi | `runtime_control` | `bind_runtime` and the `controlled_*` envelopes | `runtime_control_state` | | `plugin_install` | `install_plugins`, `remove_plugins` | `plugins_result` | | `agent_session` | `start_agent_session`, `submit_user_turn`, `end_session` | `thread_event`, `agent_session_ended`, `session_command_accepted`, `session_command_rejected` | -| `model_proxy` | Model-bound session starts | | +| `model_proxy` | Model-bound session starts | `model_adapter` in `hello_accepted` (see below) | | `revision_delivery` | `deliver_revision`, `upload_grant` | `revision_result`, `upload_grant_needed` | | `revision_restore` | `start_agent_session` with `prior_revision` | Restore failure codes in `agent_session_ended` | @@ -78,7 +78,16 @@ fact). See [Runtime control](../controller/runtime-control.md). per plugin; `plugins_failed` means the whole execution could not run. - **Agent sessions.** A session names the agent plugin and version, the clone execution whose checkout it uses (never a Node path), a Git identity that cannot corrupt a signature line, and the first user - turn (text, at most 64 KiB), optionally `model_binding_id` and `prior_revision`. `thread_event` + turn (text, at most 64 KiB), optionally `model_binding_id` and `prior_revision`. A session for a + plugin-owned model adapter also carries `agent_configuration`: the plugin id, version and target, + package SHA-256, schema and adapter contract versions, declaration fingerprint, configuration + revision, validated Settings as JSON text (an object of at most 256 KiB), protocol and the bound + model's id, name, context window and output limit. It always comes with a `model_binding_id` and + never with a credential. A Node advertises what it can run that way as `model_adapter` beside + `model_proxy` in `hello_accepted` (contract version, target, protocols); the Controller dispatches + such a session only to a Node whose `model_adapter` admits the frozen configuration. An older Node + advertises none and is never sent one; it would reject the unknown field rather than ignore it. + `thread_event` carries one settled `ora-history` record as an opaque JSON object of at most 256 KiB (`truncated` marks a shortened one) and shares the execution's gap-free sequence with the terminal `agent_session_ended`, whose `detail` is a short snake_case code. Commands carry a `command_id`; diff --git a/docs/node/protocol.zh.md b/docs/node/protocol.zh.md index b794afe62..6e6a767d8 100644 --- a/docs/node/protocol.zh.md +++ b/docs/node/protocol.zh.md @@ -38,7 +38,7 @@ ADR。 | `runtime_control` | `bind_runtime` 和各个 `controlled_*` 信封 | `runtime_control_state` | | `plugin_install` | `install_plugins`、`remove_plugins` | `plugins_result` | | `agent_session` | `start_agent_session`、`submit_user_turn`、`end_session` | `thread_event`、`agent_session_ended`、`session_command_accepted`、`session_command_rejected` | -| `model_proxy` | 绑定了模型的会话启动 | | +| `model_proxy` | 绑定了模型的会话启动 | `hello_accepted` 中的 `model_adapter`(见下文) | | `revision_delivery` | `deliver_revision`、`upload_grant` | `revision_result`、`upload_grant_needed` | | `revision_restore` | 带 `prior_revision` 的 `start_agent_session` | `agent_session_ended` 中的恢复失败代码 | @@ -67,6 +67,11 @@ operation id;`input_closed`;以及签发和过期时间。`ControlledClone` 的多个下载地址,每个都带小写的 SHA-256。单项失败按插件报告;`plugins_failed` 表示整个执行无法运行。 - **Agent 会话。** 会话指明 Agent 插件和版本、它所用 checkout 对应的 clone 执行(从不是 Node 路径)、一个不会破坏 签名行的 Git 身份,以及第一个用户轮次(文本,最多 64 KiB),可选 `model_binding_id` 和 `prior_revision`。 + 由插件自带模型 adapter 的会话还带 `agent_configuration`:插件 id、版本和 target,包的 SHA-256,schema 和 adapter + 契约版本,声明指纹,配置修订号,以 JSON 文本保存的已校验 Settings(一个最多 256 KiB 的对象),协议,以及绑定模型的 + id、名称、上下文窗口和输出上限。它总是与 `model_binding_id` 一起出现,从不携带凭据。Node 在 `hello_accepted` 里于 + `model_proxy` 旁用 `model_adapter` 宣告它能这样运行什么(契约版本、target、协议);Controller 只把这类会话派发给 + `model_adapter` 能接纳该冻结配置的 Node。旧 Node 不宣告,也不会收到这类会话;收到时它会拒绝未知字段,而不是忽略。 `thread_event` 携带一条已定稿的 `ora-history` 记录,作为最多 256 KiB 的不透明 JSON 对象(`truncated` 标记被截短的 记录),并与终止的 `agent_session_ended` 共享该执行无间隙的序号,后者的 `detail` 是一个简短的 snake_case 代码。 命令带 `command_id`;它们的回复没有序号,不需要确认,丢失的回复靠重发找回。 From 72afd0520d4b056ef9b86f76662f9948b3a7b175 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 14:04:19 +0000 Subject: [PATCH 17/18] build(controller-proto): pin the Cloud main commit that contains the adapter contract third_party/cloud moves from ora-space/cloud#106's branch head 132cc6a to Cloud main 4d35aad, which merged it. proto/ is identical between the two, and proto:check regenerates the committed client without a diff. --- third_party/cloud | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/cloud b/third_party/cloud index 132cc6a88..4d35aade4 160000 --- a/third_party/cloud +++ b/third_party/cloud @@ -1 +1 @@ -Subproject commit 132cc6a88c518c267774a37cdb2d4570dfd0968e +Subproject commit 4d35aade44fb0a2c7a088b18b6c2dfe94d294d64 From 5fdfef5fb23d5bef871a7282fffdaa9aa4f77a2f Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 14:04:19 +0000 Subject: [PATCH 18/18] docs(node): state the session's own variables and the connection-level rejection A configured session keeps its own variables beside PATH, LANG, LC_ALL and TMPDIR, and an older Node that cannot decode agent_configuration closes the whole control session as a protocol violation rather than refusing only that start. --- docs/node/agent-sessions.md | 4 ++-- docs/node/agent-sessions.zh.md | 2 +- docs/node/protocol.md | 5 ++++- docs/node/protocol.zh.md | 4 +++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/node/agent-sessions.md b/docs/node/agent-sessions.md index 99fb1d321..784124837 100644 --- a/docs/node/agent-sessions.md +++ b/docs/node/agent-sessions.md @@ -49,8 +49,8 @@ already in use is refused); `prompt_session_as_message` records the host's messa The run's Git identity is exported as `GIT_AUTHOR_*`/`GIT_COMMITTER_*` on the plugin process and on every process the host spawns for it; the Node writes no Git configuration. For a session with a -frozen Agent configuration, these variables join an otherwise empty environment that keeps only -`PATH`, `LANG`, `LC_ALL` and `TMPDIR`, so model credentials, proxy settings and CLI configuration in +frozen Agent configuration, these variables and the session's other own variables join an otherwise +empty environment that keeps only `PATH`, `LANG`, `LC_ALL` and `TMPDIR`, so model credentials, proxy settings and CLI configuration in the Node's environment never reach the agent (see [Agent configuration](../plugins/agent-configuration.md)). Any other session layers them on the inherited environment, where the Node's own model credentials and proxy settings still reach the diff --git a/docs/node/agent-sessions.zh.md b/docs/node/agent-sessions.zh.md index d13dac0d7..2221a7423 100644 --- a/docs/node/agent-sessions.zh.md +++ b/docs/node/agent-sessions.zh.md @@ -40,7 +40,7 @@ Deno 路径必须是绝对路径,超时必须为正数。有这个配置段时 `prompt_session_as_message` 把宿主的消息身份记为用户消息中每个块的 ACP `messageId`。 运行的 Git 身份以 `GIT_AUTHOR_*`/`GIT_COMMITTER_*` 导出到插件进程以及宿主为它启动的每个进程上;Node 不写任何 Git 配置。 -带冻结 Agent 配置的会话里,这些变量加在一个只保留 `PATH`、`LANG`、`LC_ALL` 和 `TMPDIR` 的空环境上,Node 环境中的 +带冻结 Agent 配置的会话里,这些变量和会话自带的其他变量加在一个只保留 `PATH`、`LANG`、`LC_ALL` 和 `TMPDIR` 的空环境上,Node 环境中的 模型凭据、代理设置和 CLI 配置都不会到达 Agent(见 [Agent 配置](../plugins/agent-configuration.zh.md))。其他会话仍叠加在 继承的环境之上,Node 自己的模型凭据和代理设置会到达 Agent。 diff --git a/docs/node/protocol.md b/docs/node/protocol.md index 0435bab90..49d7f804c 100644 --- a/docs/node/protocol.md +++ b/docs/node/protocol.md @@ -86,7 +86,10 @@ fact). See [Runtime control](../controller/runtime-control.md). never with a credential. A Node advertises what it can run that way as `model_adapter` beside `model_proxy` in `hello_accepted` (contract version, target, protocols); the Controller dispatches such a session only to a Node whose `model_adapter` admits the frozen configuration. An older Node - advertises none and is never sent one; it would reject the unknown field rather than ignore it. + advertises none and is never sent one. It would not ignore the unknown field: the message fails to + decode, which is a protocol violation, so the Node closes the whole control session (`1002` on + WebSocket, end of stream on IPC) and discards its unaccepted queued work, not just that session + start (see [Control session](control-session.md#closing)). `thread_event` carries one settled `ora-history` record as an opaque JSON object of at most 256 KiB (`truncated` marks a shortened one) and shares the execution's gap-free sequence with the terminal diff --git a/docs/node/protocol.zh.md b/docs/node/protocol.zh.md index 6e6a767d8..379615508 100644 --- a/docs/node/protocol.zh.md +++ b/docs/node/protocol.zh.md @@ -71,7 +71,9 @@ operation id;`input_closed`;以及签发和过期时间。`ControlledClone` 契约版本,声明指纹,配置修订号,以 JSON 文本保存的已校验 Settings(一个最多 256 KiB 的对象),协议,以及绑定模型的 id、名称、上下文窗口和输出上限。它总是与 `model_binding_id` 一起出现,从不携带凭据。Node 在 `hello_accepted` 里于 `model_proxy` 旁用 `model_adapter` 宣告它能这样运行什么(契约版本、target、协议);Controller 只把这类会话派发给 - `model_adapter` 能接纳该冻结配置的 Node。旧 Node 不宣告,也不会收到这类会话;收到时它会拒绝未知字段,而不是忽略。 + `model_adapter` 能接纳该冻结配置的 Node。旧 Node 不宣告,也不会收到这类会话。它不会忽略这个未知字段:消息解码失败属于协议违规,Node 会关闭整个控制会话 + (WebSocket 上是 `1002`,IPC 上直接结束流),并丢弃其中尚未接纳的排队工作,而不只是拒绝这一次会话启动(见 + [控制会话](control-session.zh.md#关闭))。 `thread_event` 携带一条已定稿的 `ora-history` 记录,作为最多 256 KiB 的不透明 JSON 对象(`truncated` 标记被截短的 记录),并与终止的 `agent_session_ended` 共享该执行无间隙的序号,后者的 `detail` 是一个简短的 snake_case 代码。 命令带 `command_id`;它们的回复没有序号,不需要确认,丢失的回复靠重发找回。