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-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 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/fixtures/echo-agent/agent.rs b/apps/ora-node/fixtures/echo-agent/agent.rs index b8e402a3f..20115695f 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,21 @@ 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, 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={} 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(), + } + } 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/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/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/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/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/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..f1c649152 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; @@ -20,9 +21,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; @@ -149,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. @@ -172,6 +177,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() && 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(), + 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..34514e192 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::{ @@ -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,6 +129,13 @@ where let Ok(plugin_id) = PluginId::parse(spec.agent_plugin_id.as_str()) else { return SessionEnd::agent_failed("agent_plugin_unavailable"); }; + // 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()); + } // 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( @@ -170,8 +181,19 @@ 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 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), Err(error) if error.0 == "model_session_ending" => { return match super::model::reconcile( @@ -241,7 +263,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) => { @@ -281,6 +305,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..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, @@ -146,11 +147,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..ffab5eb2d 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)] @@ -190,6 +196,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,8 +262,9 @@ 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_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")), @@ -311,6 +319,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(); @@ -430,6 +439,7 @@ 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"), 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/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/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..ffc5f8ec5 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,180 @@ 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, 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()); + 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 host_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)) + ); +} + +/// 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()) + ); +} + +/// 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()) + ); +} diff --git a/apps/ora-node/tests/agent_session/support.rs b/apps/ora-node/tests/agent_session/support.rs index c7189a4ea..29bf48987 100644 --- a/apps/ora-node/tests/agent_session/support.rs +++ b/apps/ora-node/tests/agent_session/support.rs @@ -1,14 +1,16 @@ //! 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, InstalledConfigurationError, + 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 +20,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"; @@ -247,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 { @@ -262,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. @@ -303,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 { @@ -319,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"); @@ -404,13 +427,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(), @@ -428,6 +466,7 @@ impl Fixture { self.package_root_of(installed_version), )]), leases: Arc::clone(&self.leases), + configuration: Arc::clone(&self.configuration), }, self.restorer.clone(), ) @@ -450,6 +489,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 +503,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"), 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(); 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/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..1354a3386 --- /dev/null +++ b/crates/node-protocol/src/domain/model_adapter.rs @@ -0,0 +1,140 @@ +//! 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: 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)] +#[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, + /// 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, + 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 + || !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..b257c4c31 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,116 @@ 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?; + // 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 [ + ( + 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}/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(), 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}; 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(()) +} diff --git a/docs/node/agent-sessions.md b/docs/node/agent-sessions.md index 54977670f..784124837 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 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 +agent. ### Separate workload user diff --git a/docs/node/agent-sessions.zh.md b/docs/node/agent-sessions.zh.md index 78820994c..2221a7423 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..49d7f804c 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,19 @@ 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 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 `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..379615508 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,13 @@ 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 不宣告,也不会收到这类会话。它不会忽略这个未知字段:消息解码失败属于协议违规,Node 会关闭整个控制会话 + (WebSocket 上是 `1002`,IPC 上直接结束流),并丢弃其中尚未接纳的排队工作,而不只是拒绝这一次会话启动(见 + [控制会话](control-session.zh.md#关闭))。 `thread_event` 携带一条已定稿的 `ora-history` 记录,作为最多 256 KiB 的不透明 JSON 对象(`truncated` 标记被截短的 记录),并与终止的 `agent_session_ended` 共享该执行无间隙的序号,后者的 `detail` 是一个简短的 snake_case 代码。 命令带 `command_id`;它们的回复没有序号,不需要确认,丢失的回复靠重发找回。 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) diff --git a/third_party/cloud b/third_party/cloud index 7a1a2aff3..4d35aade4 160000 --- a/third_party/cloud +++ b/third_party/cloud @@ -1 +1 @@ -Subproject commit 7a1a2aff3d4ffef0cec0ce7a833c9429b444a734 +Subproject commit 4d35aade44fb0a2c7a088b18b6c2dfe94d294d64