diff --git a/cli/golem-cli/src/app/build/gen_bridge.rs b/cli/golem-cli/src/app/build/gen_bridge.rs index f126f5a194..408d545275 100644 --- a/cli/golem-cli/src/app/build/gen_bridge.rs +++ b/cli/golem-cli/src/app/build/gen_bridge.rs @@ -10,7 +10,10 @@ use crate::bridge_gen::scala::tool::ScalaToolBridgeGenerator; use crate::bridge_gen::scala::{ScalaBridgeGenerator, ScalaBridgeMode}; use crate::bridge_gen::typescript::tool::TypeScriptToolBridgeGenerator; use crate::bridge_gen::typescript::{TypeScriptBridgeGenerator, TypeScriptBridgeMode}; -use crate::bridge_gen::{BridgeGenerator, BridgeMode, bridge_client_directory_name}; +use crate::bridge_gen::{ + BridgeGenerator, BridgeMode, bridge_client_directory_name, + validate_host_managed_agent_bridge_policy, +}; use crate::command::GolemCliCommand; use crate::error::NonSuccessfulExit; use crate::fs; @@ -80,6 +83,11 @@ async fn gen_bridge_with_manifest_mode_filter_and_additional_collision_targets( ) -> anyhow::Result<()> { let plan = plan_bridge_generation(ctx, manifest_bridge_mode_filter).await?; + let mut collision_targets = additional_collision_targets.to_vec(); + collision_targets.extend(plan.targets.iter().cloned()); + validate_supported_bridge_targets(&collision_targets)?; + validate_host_managed_bridge_targets(&collision_targets)?; + if plan.targets.is_empty() { if !additional_collision_targets.is_empty() { validate_no_output_dir_collisions(additional_collision_targets)?; @@ -87,9 +95,6 @@ async fn gen_bridge_with_manifest_mode_filter_and_additional_collision_targets( return Ok(()); } - let mut collision_targets = additional_collision_targets.to_vec(); - collision_targets.extend(plan.targets.iter().cloned()); - validate_supported_bridge_targets(&collision_targets)?; validate_no_output_dir_collisions(&collision_targets)?; write_repl_metadata(ctx, &plan).await?; @@ -341,6 +346,8 @@ pub(crate) async fn gen_bridge_sdk_targets( ctx: &BuildContext<'_>, targets: Vec, ) -> anyhow::Result<()> { + validate_host_managed_bridge_targets(&targets)?; + for target in targets { gen_bridge_sdk_target(ctx, target).await?; } @@ -348,6 +355,19 @@ pub(crate) async fn gen_bridge_sdk_targets( Ok(()) } +pub(crate) fn validate_host_managed_bridge_targets( + targets: &[BridgeSdkTarget], +) -> anyhow::Result<()> { + for target in targets { + let BridgeSdkTargetSubject::Agent(agent) = &target.subject else { + continue; + }; + validate_host_managed_agent_bridge_policy(agent, target.bridge_mode)?; + } + + Ok(()) +} + async fn collect_manifest_targets( ctx: &BuildContext<'_>, bridge_mode_filter: Option, @@ -1215,10 +1235,14 @@ mod tests { use crate::model::app::{Application, ApplicationPreload, ComponentPresetSelector}; use crate::model::app_raw; use golem_common::model::Empty; - use golem_common::model::agent::{AgentMode, AgentTypeName, Snapshotting}; + use golem_common::model::agent::{AgentConfigSource, AgentMode, AgentTypeName, Snapshotting}; use golem_common::model::component::ComponentName; + use golem_common::schema::agent::AgentConfigDeclarationSchema; use golem_common::schema::tool::{CommandNode, CommandTree, Doc, Globals, Tool}; - use golem_common::schema::{AgentConstructorSchema, AgentTypeSchema, InputSchema, SchemaGraph}; + use golem_common::schema::{ + AgentConstructorSchema, AgentMethodSchema, AgentTypeSchema, AutoInjectedKind, InputSchema, + NamedField, OutputSchema, SchemaGraph, SchemaType, + }; use indoc::indoc; use strum::IntoEnumIterator; use tempfile::{TempDir, tempdir}; @@ -1370,6 +1394,162 @@ mod tests { ); } + #[test] + fn external_bridge_rejects_host_managed_method_types_before_touching_output() { + let temp_dir = tempdir().unwrap(); + let output_dir = temp_dir.path().join("bridge/agent-client"); + std::fs::create_dir_all(&output_dir).unwrap(); + let sentinel = output_dir.join("sentinel"); + std::fs::write(&sentinel, "keep").unwrap(); + + let mut target = bridge_sdk_target_with_mode( + "Agent", + GuestLanguage::Rust, + BridgeMode::External, + output_dir, + ); + let agent = match &mut target.subject { + BridgeSdkTargetSubject::Agent(agent) => agent, + BridgeSdkTargetSubject::Tool(_) => unreachable!(), + }; + agent.methods.push(AgentMethodSchema { + name: "forward".to_string(), + description: String::new(), + prompt_hint: None, + input_schema: InputSchema::parameters([NamedField::user_supplied( + "credentials", + SchemaType::list(SchemaType::secret(Default::default())), + )]), + output_schema: OutputSchema::Unit, + http_endpoint: vec![], + read_only: None, + }); + + let error = validate_host_managed_bridge_targets(&[target]) + .unwrap_err() + .to_string(); + assert!(error.contains("method `forward` input parameter `credentials`")); + assert!(error.contains("host-managed capability `secret`")); + assert!(sentinel.exists(), "preflight must not modify bridge output"); + } + + #[test] + fn guest_bridge_allows_host_managed_method_inputs_and_outputs() { + let mut target = bridge_sdk_target_with_mode( + "Agent", + GuestLanguage::Rust, + BridgeMode::Guest, + tempdir().unwrap().path().join("bridge/agent-client"), + ); + let agent = match &mut target.subject { + BridgeSdkTargetSubject::Agent(agent) => agent, + BridgeSdkTargetSubject::Tool(_) => unreachable!(), + }; + agent.methods.push(AgentMethodSchema { + name: "forward".to_string(), + description: String::new(), + prompt_hint: None, + input_schema: InputSchema::parameters([NamedField::user_supplied( + "credentials", + SchemaType::secret(Default::default()), + )]), + output_schema: OutputSchema::Single(Box::new(SchemaType::permission_card( + Default::default(), + ))), + http_endpoint: vec![], + read_only: None, + }); + + validate_host_managed_bridge_targets(&[target]).unwrap(); + } + + #[test] + fn guest_bridge_rejects_host_managed_constructor_and_configuration_types() { + let mut constructor_target = bridge_sdk_target_with_mode( + "Agent", + GuestLanguage::Rust, + BridgeMode::Guest, + tempdir().unwrap().path().join("bridge/constructor-client"), + ); + let constructor_agent = match &mut constructor_target.subject { + BridgeSdkTargetSubject::Agent(agent) => agent, + BridgeSdkTargetSubject::Tool(_) => unreachable!(), + }; + constructor_agent.constructor.input_schema = InputSchema::parameters([ + NamedField::user_supplied( + "authorization", + SchemaType::permission_card(Default::default()), + ), + NamedField::auto_injected( + "host-secret", + AutoInjectedKind::Principal, + SchemaType::secret(Default::default()), + ), + ]); + + let error = validate_host_managed_bridge_targets(&[constructor_target]) + .unwrap_err() + .to_string(); + assert!(error.contains("constructor parameter `authorization`")); + assert!(error.contains("host-managed capability `permission-card`")); + + let mut config_target = bridge_sdk_target_with_mode( + "Agent", + GuestLanguage::Rust, + BridgeMode::Guest, + tempdir().unwrap().path().join("bridge/config-client"), + ); + let config_agent = match &mut config_target.subject { + BridgeSdkTargetSubject::Agent(agent) => agent, + BridgeSdkTargetSubject::Tool(_) => unreachable!(), + }; + config_agent.config.push(AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["limits".to_string()], + value_type: SchemaType::quota_token(Default::default()), + }); + + let error = validate_host_managed_bridge_targets(&[config_target]) + .unwrap_err() + .to_string(); + assert!(error.contains("configuration `limits`")); + assert!(error.contains("host-managed capability `quota-token`")); + } + + #[test] + fn bridge_preflight_allows_host_supplied_capabilities() { + let mut target = bridge_sdk_target_with_mode( + "Agent", + GuestLanguage::Rust, + BridgeMode::External, + tempdir().unwrap().path().join("bridge/agent-client"), + ); + let agent = match &mut target.subject { + BridgeSdkTargetSubject::Agent(agent) => agent, + BridgeSdkTargetSubject::Tool(_) => unreachable!(), + }; + agent.config.push(AgentConfigDeclarationSchema { + source: AgentConfigSource::Secret, + path: vec!["credentials".to_string()], + value_type: SchemaType::secret(Default::default()), + }); + agent.methods.push(AgentMethodSchema { + name: "inspect".to_string(), + description: String::new(), + prompt_hint: None, + input_schema: InputSchema::parameters([NamedField::auto_injected( + "authority", + AutoInjectedKind::Principal, + SchemaType::permission_card(Default::default()), + )]), + output_schema: OutputSchema::Unit, + http_endpoint: vec![], + read_only: None, + }); + + validate_host_managed_bridge_targets(&[target]).unwrap(); + } + #[test] fn dependency_guest_bridge_support_accepts_all_current_languages_for_agents_and_tools() { let component_name = ComponentName("component".to_string()); diff --git a/cli/golem-cli/src/app/build/mod.rs b/cli/golem-cli/src/app/build/mod.rs index 2f6a0bc82d..c47fadb809 100644 --- a/cli/golem-cli/src/app/build/mod.rs +++ b/cli/golem-cli/src/app/build/mod.rs @@ -21,8 +21,8 @@ use crate::app::build::gen_bridge::{ plan_dependency_guest_bridge_generation_for_components_lenient, plan_explicit_manifest_guest_bridge_generation_for_components_lenient, plan_manifest_external_bridge_generation_for_components_lenient, - plan_repl_bridge_generation_lenient, validate_no_output_dir_collisions, - validate_supported_bridge_targets, write_repl_metadata, + plan_repl_bridge_generation_lenient, validate_host_managed_bridge_targets, + validate_no_output_dir_collisions, validate_supported_bridge_targets, write_repl_metadata, }; use crate::app::context::BuildContext; use crate::bridge_gen::BridgeMode; @@ -395,6 +395,7 @@ fn validate_and_filter_new_bridge_targets( .map(|(_, target)| target.clone()) .collect::>(); validate_supported_bridge_targets(&exact_targets)?; + validate_host_managed_bridge_targets(&exact_targets)?; validate_no_output_dir_collisions(&exact_targets)?; validate_exact_targets_against_claims(&tagged_targets, claims)?; diff --git a/cli/golem-cli/src/bridge_gen/mod.rs b/cli/golem-cli/src/bridge_gen/mod.rs index c5d5d385af..7736425785 100644 --- a/cli/golem-cli/src/bridge_gen/mod.rs +++ b/cli/golem-cli/src/bridge_gen/mod.rs @@ -37,10 +37,12 @@ pub mod type_naming; pub mod typescript; use camino::Utf8Path; -use golem_common::model::agent::AgentTypeName; +use golem_common::model::agent::{AgentConfigSource, AgentTypeName}; use golem_common::schema::graph::reachable_defs; use golem_common::schema::schema_type::{NamedFieldType, SchemaType}; -use golem_common::schema::{AgentTypeSchema, InputSchema, SchemaGraph}; +use golem_common::schema::{ + AgentTypeSchema, FieldSource, InputSchema, SchemaGraph, find_host_managed_type, +}; use heck::ToKebabCase; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; @@ -89,6 +91,92 @@ pub fn bridge_client_directory_name(agent_type_name: &AgentTypeName, mode: Bridg } } +pub(crate) fn validate_host_managed_agent_bridge_policy( + agent: &AgentTypeSchema, + mode: BridgeMode, +) -> anyhow::Result<()> { + for field in agent.constructor.input_schema.fields() { + if matches!(field.source, FieldSource::UserSupplied) { + validate_host_managed_bridge_root( + agent, + mode, + &field.schema, + &format!("constructor parameter `{}`", field.name), + )?; + } + } + + for config in &agent.config { + if config.source == AgentConfigSource::Local { + validate_host_managed_bridge_root( + agent, + mode, + &config.value_type, + &format!("configuration `{}`", config.path.join(".")), + )?; + } + } + + if mode == BridgeMode::External { + for method in &agent.methods { + for field in method.input_schema.fields() { + if matches!(field.source, FieldSource::UserSupplied) { + validate_host_managed_bridge_root( + agent, + mode, + &field.schema, + &format!("method `{}` input parameter `{}`", method.name, field.name), + )?; + } + } + + if let Some(output) = method.output_schema.schema() { + validate_host_managed_bridge_root( + agent, + mode, + output, + &format!("method `{}` output", method.name), + )?; + } + } + } + + Ok(()) +} + +fn validate_host_managed_bridge_root( + agent: &AgentTypeSchema, + mode: BridgeMode, + ty: &SchemaType, + root: &str, +) -> anyhow::Result<()> { + let occurrence = find_host_managed_type(&agent.schema, ty).map_err(|error| { + anyhow::anyhow!( + "cannot validate {root} for {mode} bridge SDK agent `{}`: {error}", + agent.type_name + ) + })?; + + if let Some(occurrence) = occurrence { + let supported_position = match mode { + BridgeMode::External => { + "host-managed capabilities are not supported by external bridge SDKs" + } + BridgeMode::Guest => { + "host-managed capabilities are supported only in guest RPC method inputs and outputs" + } + }; + anyhow::bail!( + "cannot generate {mode} bridge SDK for agent `{}`: {root} contains host-managed capability `{}` at {}; {supported_position}", + agent.type_name, + occurrence.kind.kind_name(), + occurrence.path, + ); + } + + Ok(()) +} + pub(crate) fn projected_schema_graph(graph: &SchemaGraph, root: &SchemaType) -> SchemaGraph { SchemaGraph { defs: reachable_defs(graph, root), diff --git a/cli/golem-cli/src/bridge_gen/moonbit/mod.rs b/cli/golem-cli/src/bridge_gen/moonbit/mod.rs index 660f65047e..adfdf6a21b 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/mod.rs +++ b/cli/golem-cli/src/bridge_gen/moonbit/mod.rs @@ -40,7 +40,7 @@ use crate::bridge_gen::moonbit::moonbit::{ use crate::bridge_gen::type_naming::{TypeNaming, user_supplied_fields}; use crate::bridge_gen::{ BridgeGenerator, BridgeMode, bridge_client_directory_name, projected_input_schema_graph, - projected_schema_graph, + projected_schema_graph, validate_host_managed_agent_bridge_policy, }; use crate::fs; use crate::sdk_overrides::{sdk_overrides, workspace_root}; @@ -217,6 +217,7 @@ impl MoonBitBridgeGenerator { mode: MoonBitBridgeMode, extra_reserved_names: impl IntoIterator, ) -> anyhow::Result { + validate_host_managed_agent_bridge_policy(&agent_type, mode.bridge_mode())?; let same_language = agent_type.source_language.eq_ignore_ascii_case("moonbit"); let mut reserved_names = RESERVED_TYPE_NAMES @@ -427,6 +428,8 @@ impl MoonBitBridgeGenerator { ), ("@rpc.", "\"golemcloud/golem_sdk/rpc\""), ("@model.", "\"golemcloud/golem_sdk/schema_model\" @model"), + ("@schema.", "\"golemcloud/golem_sdk/schema\""), + ("@quota.", "\"golemcloud/golem_sdk/quota\""), ( "@model_host.", "\"golemcloud/golem_sdk/schema_model_host\" @model_host", @@ -513,8 +516,11 @@ impl MoonBitBridgeGenerator { name: &str, resolved: &SchemaType, ) -> anyhow::Result<()> { - let derives = if self.mode == MoonBitBridgeMode::ExternalRest - && contains_stream_in_graph(&self.agent_type.schema, resolved) + let derives = if (self.mode == MoonBitBridgeMode::ExternalRest + && contains_stream_in_graph(&self.agent_type.schema, resolved)) + || (self.mode == MoonBitBridgeMode::GuestWasmRpc + && golem_common::schema::find_host_managed_type(&self.agent_type.schema, resolved)? + .is_some()) { "" } else { @@ -679,7 +685,21 @@ impl MoonBitBridgeGenerator { writer.line(format!("{}({ty})", mm.case_idents[idx])); } writer.dedent(); - writer.line(format!("}} {}", self.type_derives())); + let mut derives = self.type_derives(); + if self.mode == MoonBitBridgeMode::GuestWasmRpc { + for (_, payload) in &mm.cases { + if golem_common::schema::find_host_managed_type( + &self.agent_type.schema, + payload, + )? + .is_some() + { + derives = ""; + break; + } + } + } + writer.line(format!("}} {derives}")); writer.blank(); // encode @@ -2636,6 +2656,15 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ "Bare text/binary rich scalars have no MoonBit bridge encoding; \ wrap them in the unstructured text/binary variant ({resolved:?})" ), + SchemaType::Secret { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!("@model.SchemaValue::Secret({val})") + } + SchemaType::QuotaToken { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!("@schema.to_value_as({val})") + } + SchemaType::PermissionCard { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!("@model.SchemaValue::PermissionCard({val})") + } SchemaType::Quantity { .. } | SchemaType::Secret { .. } | SchemaType::QuotaToken { .. } @@ -2808,6 +2837,19 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ "Bare text/binary rich scalars have no MoonBit bridge decoding; \ wrap them in the unstructured text/binary variant ({resolved:?})" ), + SchemaType::Secret { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!( + "match {val} {{ @model.SchemaValue::Secret(handle) => handle; other => codec_mismatch(\"secret\", other) }}" + ) + } + SchemaType::QuotaToken { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!("@schema.from_value_as({val})") + } + SchemaType::PermissionCard { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + format!( + "match {val} {{ @model.SchemaValue::PermissionCard(handle) => handle; other => codec_mismatch(\"permission-card\", other) }}" + ) + } SchemaType::Quantity { .. } | SchemaType::Secret { .. } | SchemaType::QuotaToken { .. } @@ -2991,6 +3033,15 @@ fn guest_decode_unstructured_binary(value : @model.SchemaValue, allowed : Array[ "Bare text/binary rich scalars have no MoonBit bridge type; \ wrap them in the unstructured text/binary variant ({resolved:?})" ), + SchemaType::Secret { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + Ok("@model.GuestSecretHandle".to_string()) + } + SchemaType::QuotaToken { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + Ok("@quota.QuotaToken".to_string()) + } + SchemaType::PermissionCard { .. } if self.mode == MoonBitBridgeMode::GuestWasmRpc => { + Ok("@model.GuestPermissionCardHandle".to_string()) + } SchemaType::Quantity { .. } | SchemaType::Secret { .. } | SchemaType::QuotaToken { .. } diff --git a/cli/golem-cli/src/bridge_gen/rust/mod.rs b/cli/golem-cli/src/bridge_gen/rust/mod.rs index 87cec57038..dca6aa192c 100644 --- a/cli/golem-cli/src/bridge_gen/rust/mod.rs +++ b/cli/golem-cli/src/bridge_gen/rust/mod.rs @@ -25,7 +25,10 @@ use crate::bridge_gen::parameter_naming::ParameterNaming; use crate::bridge_gen::rust::rust::{is_valid_rust_ident, to_rust_ident}; use crate::bridge_gen::type_naming::{TypeNaming, user_supplied_fields}; -use crate::bridge_gen::{BridgeGenerator, BridgeMode, bridge_client_directory_name}; +use crate::bridge_gen::{ + BridgeGenerator, BridgeMode, bridge_client_directory_name, + validate_host_managed_agent_bridge_policy, +}; use crate::fs; use crate::sdk_overrides::{sdk_overrides, workspace_root}; use anyhow::{anyhow, bail}; @@ -35,6 +38,7 @@ use golem_common::schema::agent::{ AgentConfigDeclarationSchema, AgentMethodSchema, AgentTypeSchema, InputSchema, OutputSchema, contains_stream_in_graph, typed_schema_value_with_projected_defs, }; +use golem_common::schema::find_host_managed_type; use golem_common::schema::graph::{SchemaGraph, SchemaTypeDef}; use golem_common::schema::multimodal::multimodal_variant_cases; use golem_common::schema::schema_type::{ @@ -65,6 +69,15 @@ pub enum RustBridgeMode { GuestWasmRpc, } +impl RustBridgeMode { + fn bridge_mode(self) -> BridgeMode { + match self { + RustBridgeMode::ExternalRest => BridgeMode::External, + RustBridgeMode::GuestWasmRpc => BridgeMode::Guest, + } + } +} + struct GuestMethodNames { await_name: Ident, trigger_name: Ident, @@ -271,6 +284,7 @@ impl RustBridgeGenerator { mode: RustBridgeMode, extra: impl IntoIterator, ) -> anyhow::Result { + validate_host_managed_agent_bridge_policy(&agent_type, mode.bridge_mode())?; let same_language = agent_type.source_language.eq_ignore_ascii_case("rust"); let type_naming = match mode { RustBridgeMode::ExternalRest => TypeNaming::new(&agent_type, same_language)?, @@ -623,7 +637,11 @@ impl RustBridgeGenerator { app_name: config.app_name.to_string(), env_name: config.env_name.to_string(), agent_type_name: #agent_type_name.to_string(), - parameters: constructor_parameters.clone(), + parameters: crate::__golem_bridge_runtime::schema::ExternalSchemaValue::try_from( + constructor_parameters.clone(), + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { + message: format!("Failed to validate constructor parameters: {__e}"), + })?, phantom_id, config: Some(agent_config), }, @@ -659,11 +677,19 @@ impl RustBridgeGenerator { app_name: config.app_name.to_string(), env_name: config.env_name.to_string(), agent_type_name: #agent_type_name.to_string(), - parameters: self.constructor_parameters.clone(), + parameters: crate::__golem_bridge_runtime::schema::ExternalSchemaValue::try_from( + self.constructor_parameters.clone(), + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { + message: format!("Failed to validate constructor parameters: {__e}"), + })?, phantom_id: self.phantom_id, config: #invocation_config, method_name: method_name.to_string(), - method_parameters, + method_parameters: crate::__golem_bridge_runtime::schema::ExternalSchemaValue::try_from( + method_parameters, + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { + message: format!("Failed to validate method parameters: {__e}"), + })?, mode, schedule_at, idempotency_key: None, @@ -1667,7 +1693,7 @@ impl RustBridgeGenerator { let idempotency_key = response.idempotency_key; match response.result { Some(__typed) => { - let (_, __value) = __typed.into_parts(); + let (_, __value) = __typed.into_inner().into_parts(); let __decoded: #return_type = (|| -> Result<#return_type, String> { #decode_body })().map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to decode result value: {__e}") })?; @@ -2017,7 +2043,16 @@ impl RustBridgeGenerator { Ident::new(&format!("encode_streaming_{name}"), Span::call_site()); let streaming_decode_fn = Ident::new(&format!("decode_streaming_{name}"), Span::call_site()); - let derive = if streaming { + let contains_host_managed = self.mode == RustBridgeMode::GuestWasmRpc + && cases.iter().try_fold(false, |found, (_, payload)| { + Ok::<_, anyhow::Error>( + found + || find_host_managed_type(&self.agent_type.schema, payload)?.is_some(), + ) + })?; + let derive = if contains_host_managed { + quote! {} + } else if streaming { quote! { #[derive(Debug)] } } else { quote! { #[derive(Debug, Clone)] } @@ -2176,7 +2211,11 @@ impl RustBridgeGenerator { /// Emit the `pub struct` / `pub enum` / `pub type` definition for a named /// type, given its already-resolved body. fn emit_typedef(&mut self, name: &Ident, resolved: &SchemaType) -> anyhow::Result { - let derive = if self.mode == RustBridgeMode::ExternalRest + let contains_host_managed = self.mode == RustBridgeMode::GuestWasmRpc + && find_host_managed_type(&self.agent_type.schema, resolved)?.is_some(); + let derive = if contains_host_managed { + quote! {} + } else if self.mode == RustBridgeMode::ExternalRest && contains_stream_in_graph(&self.agent_type.schema, resolved) { quote! { #[derive(Debug)] } @@ -2909,6 +2948,15 @@ impl RustBridgeGenerator { SchemaType::Duration { .. } => { quote! { Ok(crate::__golem_bridge_runtime::schema::SchemaValue::Duration(crate::__golem_bridge_runtime::schema::DurationValuePayload { nanoseconds: #val })) } } + SchemaType::Secret { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + quote! { Ok(::to_value(&#val)) } + } + SchemaType::QuotaToken { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + quote! { Ok(::to_value(&#val)) } + } + SchemaType::PermissionCard { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + quote! { Ok(::to_value(&#val)) } + } SchemaType::Ref { .. } | SchemaType::Record { .. } | SchemaType::Variant { .. } @@ -3215,6 +3263,20 @@ impl RustBridgeGenerator { SchemaType::Duration { .. } => quote! { match #val { crate::__golem_bridge_runtime::schema::SchemaValue::Duration(__p) => Ok(__p.nanoseconds), __other => Err(format!("Expected duration value, got {:?}", __other)) } }, + SchemaType::Secret { .. } if self.mode == RustBridgeMode::GuestWasmRpc => quote! { + ::from_value(&#val) + .map_err(|__error| __error.to_string()) + }, + SchemaType::QuotaToken { .. } if self.mode == RustBridgeMode::GuestWasmRpc => quote! { + ::from_value(&#val) + .map_err(|__error| __error.to_string()) + }, + SchemaType::PermissionCard { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + quote! { + ::from_value(&#val) + .map_err(|__error| __error.to_string()) + } + } SchemaType::Ref { .. } | SchemaType::Record { .. } | SchemaType::Variant { .. } @@ -3363,6 +3425,15 @@ impl RustBridgeGenerator { SchemaType::Url { .. } => Ok(quote! { String }), SchemaType::Datetime { .. } => Ok(quote! { String }), SchemaType::Duration { .. } => Ok(quote! { i64 }), + SchemaType::Secret { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + Ok(quote! { golem_rust::secrets::GuestSecretHandle }) + } + SchemaType::QuotaToken { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + Ok(quote! { golem_rust::quota::QuotaToken }) + } + SchemaType::PermissionCard { .. } if self.mode == RustBridgeMode::GuestWasmRpc => { + Ok(quote! { golem_rust::schema::wit::GuestPermissionCardHandle }) + } SchemaType::Ref { .. } | SchemaType::Variant { .. } | SchemaType::Enum { .. } diff --git a/cli/golem-cli/src/bridge_gen/scala/mod.rs b/cli/golem-cli/src/bridge_gen/scala/mod.rs index 475521a301..4c43a2ff47 100644 --- a/cli/golem-cli/src/bridge_gen/scala/mod.rs +++ b/cli/golem-cli/src/bridge_gen/scala/mod.rs @@ -48,7 +48,7 @@ use crate::bridge_gen::scala::scala_writer::ScalaWriter; use crate::bridge_gen::type_naming::{TypeNaming, user_supplied_fields}; use crate::bridge_gen::{ BridgeGenerator, BridgeMode, bridge_client_directory_name, projected_input_schema_graph, - projected_schema_graph, + projected_schema_graph, validate_host_managed_agent_bridge_policy, }; use crate::fs; use crate::sdk_overrides::sdk_overrides; @@ -483,6 +483,7 @@ impl ScalaBridgeGenerator { mode: ScalaBridgeMode, extra_reserved_names: impl IntoIterator, ) -> anyhow::Result { + validate_host_managed_agent_bridge_policy(&agent_type, mode.bridge_mode())?; let same_language = agent_type.source_language.eq_ignore_ascii_case("scala"); let runtime_config = ScalaRuntimeConfig::new(mode); @@ -2860,6 +2861,15 @@ impl ScalaBridgeGenerator { ), SchemaType::Datetime { .. } => format!("{SV}.DatetimeValue({val_expr}.toString)"), SchemaType::Duration { .. } => format!("{SV}.DurationValue({val_expr})"), + SchemaType::Secret { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + format!("{GUEST_SV}.SecretValue({val_expr})") + } + SchemaType::QuotaToken { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + format!("{GUEST_SV}.QuotaTokenHandle({val_expr}.handle)") + } + SchemaType::PermissionCard { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + format!("{GUEST_SV}.PermissionCardHandle({val_expr})") + } SchemaType::Record { .. } | SchemaType::Variant { .. } | SchemaType::Enum { .. } @@ -3017,6 +3027,17 @@ impl ScalaBridgeGenerator { SchemaType::Url { .. } => format!("{CODEC}.asUrl({val_expr})"), SchemaType::Datetime { .. } => format!("{CODEC}.asDatetime({val_expr})"), SchemaType::Duration { .. } => format!("{CODEC}.asDuration({val_expr})"), + SchemaType::Secret { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => format!( + "{val_expr} match {{ case {GUEST_SV}.SecretValue(handle) => handle; case other => throw {GUEST_CLIENT_ERROR}(s\"Expected secret value, got $other\") }}" + ), + SchemaType::QuotaToken { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => format!( + "{val_expr} match {{ case {GUEST_SV}.QuotaTokenHandle(handle) => new _root_.golem.host.QuotaApi.QuotaToken(handle); case other => throw {GUEST_CLIENT_ERROR}(s\"Expected quota-token handle, got $other\") }}" + ), + SchemaType::PermissionCard { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + format!( + "{val_expr} match {{ case {GUEST_SV}.PermissionCardHandle(handle) => handle; case other => throw {GUEST_CLIENT_ERROR}(s\"Expected permission-card handle, got $other\") }}" + ) + } SchemaType::Record { .. } | SchemaType::Variant { .. } | SchemaType::Enum { .. } @@ -3231,6 +3252,15 @@ impl ScalaBridgeGenerator { } SchemaType::Datetime { .. } => Ok("_root_.java.time.Instant".to_string()), SchemaType::Duration { .. } => Ok("_root_.scala.Long".to_string()), + SchemaType::Secret { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + Ok("_root_.golem.schema.GuestSecretHandle".to_string()) + } + SchemaType::QuotaToken { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + Ok("_root_.golem.host.QuotaApi.QuotaToken".to_string()) + } + SchemaType::PermissionCard { .. } if self.mode == ScalaBridgeMode::GuestWasmRpc => { + Ok("_root_.golem.schema.GuestPermissionCardHandle".to_string()) + } // Named composites should already have been resolved to their name // above; reaching here means the walker did not register one. SchemaType::Record { .. } diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index dae9e29249..62950b2938 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -29,7 +29,7 @@ use crate::bridge_gen::typescript::ts_writer::{ }; use crate::bridge_gen::{ BridgeGenerator, BridgeMode, bridge_client_directory_name, projected_input_schema_graph, - projected_schema_graph, + projected_schema_graph, validate_host_managed_agent_bridge_policy, }; use crate::fs; use crate::sdk_overrides::{sdk_overrides, workspace_root}; @@ -193,6 +193,7 @@ impl TypeScriptBridgeGenerator { mode: TypeScriptBridgeMode, mut reserved: Vec, ) -> anyhow::Result { + validate_host_managed_agent_bridge_policy(&agent_type, mode.bridge_mode())?; let same_language = agent_type .source_language .eq_ignore_ascii_case("typescript") @@ -2431,7 +2432,9 @@ impl TypeScriptBridgeGenerator { writer.write_line(format!("{if_or_else} (item.{case_index} === {idx}) {{")); writer.indent(); let decoded = self.decode_schema_value(&format!("item.{payload}"), schema)?; - writer.write_line(format!("return {{ type: '{name}', value: {decoded} }};")); + writer.write_line(format!( + "return {{ type: '{name}' as const, value: {decoded} }};" + )); writer.unindent(); writer.write_line("}"); } @@ -2619,7 +2622,7 @@ impl TypeScriptBridgeGenerator { ) -> anyhow::Result<()> { writer.indent(); writer.write_line(if self.mode == TypeScriptBridgeMode::GuestWasmRpc { - "{ tag: 'record', fields: [" + "base.withCapabilityAdoptionTransaction((): base.SchemaValue => ({ tag: 'record', fields: [" } else { "{ kind: 'record', value: { fields: [" }); @@ -2637,7 +2640,7 @@ impl TypeScriptBridgeGenerator { } writer.unindent(); writer.write_line(if self.mode == TypeScriptBridgeMode::GuestWasmRpc { - "] };" + "] }));" } else { "] } };" }); @@ -2861,20 +2864,26 @@ impl TypeScriptBridgeGenerator { format!("((n: any) => base.datetimeToISOString(n.value))({value})") } SchemaType::Duration { .. } => format!("((n: any) => n.nanoseconds)({value})"), + SchemaType::Secret { .. } => { + format!("base.secretHandleFromSchemaValue({value})") + } + SchemaType::QuotaToken { .. } => { + format!("base.quotaTokenFromSchemaValue({value})") + } + SchemaType::PermissionCard { .. } => { + format!("base.permissionCardHandleFromSchemaValue({value})") + } SchemaType::Ref { .. } => anyhow::bail!( "Unresolved SchemaType::Ref reached guest decode; value expr = {value}" ), SchemaType::Text { .. } | SchemaType::Binary { .. } => anyhow::bail!( "Bare text/binary rich scalars have no TypeScript bridge surface; type = {typ:?}" ), - SchemaType::Quantity { .. } - | SchemaType::Secret { .. } - | SchemaType::QuotaToken { .. } - | SchemaType::PermissionCard { .. } - | SchemaType::Future { .. } - | SchemaType::Stream { .. } => anyhow::bail!( - "SchemaType variant has no TypeScript bridge decoding yet; type = {typ:?}" - ), + SchemaType::Quantity { .. } | SchemaType::Future { .. } | SchemaType::Stream { .. } => { + anyhow::bail!( + "SchemaType variant has no TypeScript bridge decoding yet; type = {typ:?}" + ) + } }; Ok(rendered) } @@ -3269,20 +3278,26 @@ impl TypeScriptBridgeGenerator { format!("{{ tag: 'datetime', value: base.datetimeFromISOString({value}) }}") } SchemaType::Duration { .. } => format!("{{ tag: 'duration', nanoseconds: {value} }}"), + SchemaType::Secret { .. } => { + format!("base.secretHandleToSchemaValue({value})") + } + SchemaType::QuotaToken { .. } => { + format!("base.quotaTokenToSchemaValue({value})") + } + SchemaType::PermissionCard { .. } => { + format!("base.permissionCardHandleToSchemaValue({value})") + } SchemaType::Ref { .. } => anyhow::bail!( "Unresolved SchemaType::Ref reached guest encode; value expr = {value}" ), SchemaType::Text { .. } | SchemaType::Binary { .. } => anyhow::bail!( "Bare text/binary rich scalars have no TypeScript bridge surface; type = {typ:?}" ), - SchemaType::Quantity { .. } - | SchemaType::Secret { .. } - | SchemaType::QuotaToken { .. } - | SchemaType::PermissionCard { .. } - | SchemaType::Future { .. } - | SchemaType::Stream { .. } => anyhow::bail!( - "SchemaType variant has no TypeScript bridge encoding yet; type = {typ:?}" - ), + SchemaType::Quantity { .. } | SchemaType::Future { .. } | SchemaType::Stream { .. } => { + anyhow::bail!( + "SchemaType variant has no TypeScript bridge encoding yet; type = {typ:?}" + ) + } }; Ok(rendered) } @@ -4076,6 +4091,21 @@ impl TypeScriptBridgeGenerator { SchemaType::Url { .. } => Ok("string".to_string()), SchemaType::Datetime { .. } => Ok("string".to_string()), SchemaType::Duration { .. } => Ok("bigint".to_string()), + SchemaType::Secret { .. } + if self.mode == TypeScriptBridgeMode::GuestWasmRpc => + { + Ok("base.SecretHandle".to_string()) + } + SchemaType::QuotaToken { .. } + if self.mode == TypeScriptBridgeMode::GuestWasmRpc => + { + Ok("base.QuotaToken".to_string()) + } + SchemaType::PermissionCard { .. } + if self.mode == TypeScriptBridgeMode::GuestWasmRpc => + { + Ok("base.PermissionCardHandle".to_string()) + } SchemaType::Stream { inner: Some(inner), .. } => Ok(format!("base.AgentStream<{}>", self.type_reference(inner)?)), @@ -4231,6 +4261,17 @@ impl TypeScriptBridgeGenerator { SchemaType::Url { .. } => Ok("string".to_string()), SchemaType::Datetime { .. } => Ok("string".to_string()), SchemaType::Duration { .. } => Ok("bigint".to_string()), + SchemaType::Secret { .. } if self.mode == TypeScriptBridgeMode::GuestWasmRpc => { + Ok("base.SecretHandle".to_string()) + } + SchemaType::QuotaToken { .. } if self.mode == TypeScriptBridgeMode::GuestWasmRpc => { + Ok("base.QuotaToken".to_string()) + } + SchemaType::PermissionCard { .. } + if self.mode == TypeScriptBridgeMode::GuestWasmRpc => + { + Ok("base.PermissionCardHandle".to_string()) + } SchemaType::Stream { inner: Some(inner), .. } => Ok(format!("base.AgentStream<{}>", self.type_reference(inner)?)), diff --git a/cli/golem-cli/src/command_handler/agent/mod.rs b/cli/golem-cli/src/command_handler/agent/mod.rs index 3a9719a86a..47f9a26af0 100644 --- a/cli/golem-cli/src/command_handler/agent/mod.rs +++ b/cli/golem-cli/src/command_handler/agent/mod.rs @@ -77,7 +77,7 @@ use golem_common::model::worker::{ use golem_common::model::{AgentFilter, FilterComparator, IdempotencyKey, OplogIndex}; use golem_common::schema::agent::{AgentTypeSchema, InputSchema}; use golem_common::schema::graph::TypedSchemaValue; -use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; +use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaType, SchemaValue}; use crossterm::cursor::{Hide, MoveTo, Show}; use crossterm::execute; @@ -607,11 +607,13 @@ impl AgentCommandHandler { app_name: environment.application_name.to_string(), env_name: environment.environment_name.to_string(), agent_type_name: agent_id.agent_type.0.clone(), - parameters: agent_id.parameters.value().clone(), + parameters: ExternalSchemaValue::try_from(agent_id.parameters.value().clone()) + .map_err(anyhow::Error::msg)?, phantom_id: agent_id.phantom_id, config: None, method_name: method_name.clone(), - method_parameters, + method_parameters: ExternalSchemaValue::try_from(method_parameters) + .map_err(anyhow::Error::msg)?, mode, schedule_at, idempotency_key: Some(idempotency_key.value.clone()), @@ -711,10 +713,11 @@ impl AgentCommandHandler { bail!("Agent type not found: {}", agent_type_name.0); }; - let value: SchemaValue = serde_json::from_value(parameters).map_err(|err| { + let value: ExternalSchemaValue = serde_json::from_value(parameters).map_err(|err| { anyhow!("Failed to match agent type parameters to the current metadata: {err}") })?; - let typed_parameters = typed_constructor_parameters(&agent_type.agent_type, value); + let typed_parameters = + typed_constructor_parameters(&agent_type.agent_type, value.into_inner()); let agent_id = build_repl_agent_id( &agent_type.agent_type, typed_parameters, diff --git a/cli/golem-cli/src/command_handler/secret.rs b/cli/golem-cli/src/command_handler/secret.rs index 220627c2fd..48b6ce34e0 100644 --- a/cli/golem-cli/src/command_handler/secret.rs +++ b/cli/golem-cli/src/command_handler/secret.rs @@ -26,13 +26,11 @@ use crate::model::secret::{ }; use anyhow::bail; use golem_client::api::AgentSecretsClient; -use golem_client::model::AgentSecretUpdate; -use golem_common::model::agent_secret::{ - AgentSecretCreation, AgentSecretDto, AgentSecretId, AgentSecretPath, CanonicalAgentSecretPath, -}; +use golem_client::model::{AgentSecretCreation, AgentSecretDto, AgentSecretUpdate}; +use golem_common::model::agent_secret::{AgentSecretId, AgentSecretPath, CanonicalAgentSecretPath}; use golem_common::model::optional_field_update::OptionalFieldUpdate; use golem_common::schema::validation::validate_value; -use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; +use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaType, SchemaValue}; use std::collections::BTreeSet; use std::sync::Arc; @@ -144,8 +142,15 @@ impl SecretCommandHandler { } }; - let secret_value: Option = match secret_value { - Some(sv) => Some(self.parse_secret_value(&sv, &secret_type, &source_language)?), + let secret_value: Option = match secret_value { + Some(sv) => Some( + ExternalSchemaValue::try_from(self.parse_secret_value( + &sv, + &secret_type, + &source_language, + )?) + .map_err(anyhow::Error::msg)?, + ), None => None, }; @@ -194,10 +199,15 @@ impl SecretCommandHandler { let clients = self.ctx.golem_clients().await?; - let secret_value: Option = match secret_value { - Some(sv) => { - Some(self.parse_secret_value(&sv, ¤t.secret_type, &source_language)?) - } + let secret_value: Option = match secret_value { + Some(sv) => Some( + ExternalSchemaValue::try_from(self.parse_secret_value( + &sv, + ¤t.secret_type, + &source_language, + )?) + .map_err(anyhow::Error::msg)?, + ), None => None, }; @@ -317,14 +327,15 @@ fn parse_secret_value_to_schema_value( if let Ok(parsed_value) = parse_value_for_language(input, secret_type, &secret_type.root, source_language) { - return Ok(parsed_value); + return ExternalSchemaValue::try_from(parsed_value).map(ExternalSchemaValue::into_inner); } // Fall back to a raw schema-native `SchemaValue` JSON, but only accept it // if it conforms to `secret_type`. Without this validation a user could // store any value for any secret type and the mismatch would only surface // much later at the consumer. - match serde_json::from_str::(input) { + match serde_json::from_str::(input) { Ok(value) => { + let value = value.into_inner(); validate_value(secret_type, &secret_type.root, &value).map_err(|errs| { format!( "Secret value does not match the expected type: {}", diff --git a/cli/golem-cli/src/model/cli_output/tests.rs b/cli/golem-cli/src/model/cli_output/tests.rs index 8614644ea0..6b5cbf9a27 100644 --- a/cli/golem-cli/src/model/cli_output/tests.rs +++ b/cli/golem-cli/src/model/cli_output/tests.rs @@ -1127,9 +1127,12 @@ fn cli_output_schema_validates_schema_native_secret_outputs() { secret_type: golem_common::schema::SchemaGraph::anonymous( golem_common::schema::SchemaType::string(), ), - secret_value: Some(golem_common::schema::SchemaValue::String( - "super-secret".to_string(), - )), + secret_value: Some( + golem_common::schema::ExternalSchemaValue::try_from( + golem_common::schema::SchemaValue::String("super-secret".to_string()), + ) + .unwrap(), + ), }; let outputs = vec![ @@ -6054,7 +6057,9 @@ fn arb_secret() -> BoxedStrategy { revision: golem_common::model::agent_secret::AgentSecretRevision::new(revision) .expect("generated revision should be valid"), secret_type, - secret_value, + secret_value: secret_value.map(|value| { + golem_common::schema::ExternalSchemaValue::try_from(value).unwrap() + }), } }, ) diff --git a/cli/golem-cli/src/model/deploy.rs b/cli/golem-cli/src/model/deploy.rs index 72f48417cb..151a0b1445 100644 --- a/cli/golem-cli/src/model/deploy.rs +++ b/cli/golem-cli/src/model/deploy.rs @@ -293,7 +293,7 @@ mod tests { }; use golem_common::model::retry_policy::{RetryPolicyId, RetryPolicyRevision}; use golem_common::schema::schema_type::SchemaType; - use golem_common::schema::{SchemaGraph, SchemaValue}; + use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaValue}; use uuid::Uuid; fn schema_str() -> SchemaType { @@ -313,7 +313,7 @@ mod tests { ), revision: serde_json::from_value(serde_json::json!(0)).unwrap(), secret_type, - secret_value: value, + secret_value: value.map(|value| ExternalSchemaValue::try_from(value).unwrap()), } } diff --git a/cli/golem-cli/src/model/invoke_result_view.rs b/cli/golem-cli/src/model/invoke_result_view.rs index bc86bf0611..1461fb76c2 100644 --- a/cli/golem-cli/src/model/invoke_result_view.rs +++ b/cli/golem-cli/src/model/invoke_result_view.rs @@ -154,6 +154,6 @@ impl InvokeResultView { return Ok((false, None)); }; - Ok((false, Some(typed.clone()))) + Ok((false, Some(typed.as_inner().clone()))) } } diff --git a/cli/golem-cli/src/model/secret.rs b/cli/golem-cli/src/model/secret.rs index a699c9895a..c67c8e98f5 100644 --- a/cli/golem-cli/src/model/secret.rs +++ b/cli/golem-cli/src/model/secret.rs @@ -18,7 +18,7 @@ use crate::model::masking::{Masked, MaskingConfig, mask_json_secret_value}; use crate::model::text_format::*; use comfy_table::Cell; -use golem_common::model::agent_secret::AgentSecretDto; +use golem_client::model::AgentSecretDto; use golem_common::schema::{SchemaGraph, SchemaValue}; use serde::Serialize as _; use serde::Serializer; @@ -43,7 +43,7 @@ impl From for SecretView { path: value.path, revision: value.revision, secret_type: value.secret_type, - secret_value: value.secret_value, + secret_value: value.secret_value.map(|value| value.into_inner()), } } } @@ -337,11 +337,12 @@ impl StructuredOutput for SecretListView { mod tests { use super::{SecretGetView, SecretListView, wrap_uuid_for_table}; use crate::model::masking::{Masked, MaskingConfig}; + use golem_client::model::AgentSecretDto; use golem_common::model::agent_secret::{ - AgentSecretDto, AgentSecretId, AgentSecretRevision, CanonicalAgentSecretPath, + AgentSecretId, AgentSecretRevision, CanonicalAgentSecretPath, }; use golem_common::model::environment::EnvironmentId; - use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; + use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaType, SchemaValue}; use serde_json::json; use test_r::test; @@ -356,7 +357,10 @@ mod tests { path: CanonicalAgentSecretPath(vec!["token".to_string()]), revision: AgentSecretRevision::new(7).unwrap(), secret_type: SchemaGraph::anonymous(SchemaType::string()), - secret_value: Some(SchemaValue::String("super-secret".to_string())), + secret_value: Some( + ExternalSchemaValue::try_from(SchemaValue::String("super-secret".to_string())) + .unwrap(), + ), } .into() } diff --git a/cli/golem-cli/tests/bridge_gen/moonbit.rs b/cli/golem-cli/tests/bridge_gen/moonbit.rs index c746e02775..17dcd0d5d7 100644 --- a/cli/golem-cli/tests/bridge_gen/moonbit.rs +++ b/cli/golem-cli/tests/bridge_gen/moonbit.rs @@ -656,6 +656,76 @@ fn guest_mode_emits_standalone_schema_value_codecs_and_moon_checks() { ); } +#[test] +fn guest_mode_moon_checks_host_managed_capability_methods() { + let capability_tuple = SchemaType::tuple(vec![ + SchemaType::secret(Default::default()), + SchemaType::quota_token(Default::default()), + SchemaType::permission_card(Default::default()), + ]); + let envelope = SchemaType::record(vec![named_field( + "capabilities", + SchemaType::list(capability_tuple), + )]); + let capability_modalities = multimodal(vec![ + ("secret", SchemaType::secret(Default::default())), + ("quota", SchemaType::quota_token(Default::default())), + ( + "permission", + SchemaType::permission_card(Default::default()), + ), + ]); + let agent_type = agent( + "CapabilityAgent", + "moonbit", + vec![], + vec![ + method( + "transfer", + vec![field("envelope", ref_to("capability-envelope"))], + Some(ref_to("capability-envelope")), + ), + method( + "transferMultimodal", + vec![field("capabilities", capability_modalities.clone())], + Some(capability_modalities), + ), + ], + vec![def("capability-envelope", envelope)], + AgentMode::Durable, + ); + let guest = generate_without_check(agent_type, MoonBitBridgeMode::GuestWasmRpc); + let source = std::fs::read_to_string(guest.path().join("client/client.mbt")).unwrap(); + let package = std::fs::read_to_string(guest.path().join("client/moon.pkg")).unwrap(); + + assert!(source.contains("@model.GuestSecretHandle")); + assert!(source.contains("@quota.QuotaToken")); + assert!(source.contains("@model.GuestPermissionCardHandle")); + assert!(source.contains("@schema.to_value_as")); + assert!(source.contains("@schema.from_value_as")); + assert!(package.contains("\"golemcloud/golem_sdk/quota\"")); + assert!(package.contains("\"golemcloud/golem_sdk/schema\"")); + moon_check_wasm(guest.path()); + + let without_quota = generate_without_check( + agent( + "SecretAgent", + "moonbit", + vec![], + vec![method( + "transfer", + vec![field("secret", SchemaType::secret(Default::default()))], + Some(SchemaType::secret(Default::default())), + )], + vec![], + AgentMode::Durable, + ), + MoonBitBridgeMode::GuestWasmRpc, + ); + let package = std::fs::read_to_string(without_quota.path().join("client/moon.pkg")).unwrap(); + assert!(!package.contains("golemcloud/golem_sdk/quota")); +} + #[test] fn guest_mode_emits_native_agent_client_and_exact_config_graph() { let mut agent_type = agent( diff --git a/cli/golem-cli/tests/bridge_gen/rust.rs b/cli/golem-cli/tests/bridge_gen/rust.rs index edf0793fbc..fefcb2bdba 100644 --- a/cli/golem-cli/tests/bridge_gen/rust.rs +++ b/cli/golem-cli/tests/bridge_gen/rust.rs @@ -243,6 +243,85 @@ fn guest_generation_uses_logical_ephemeral_proxies_and_invocation_metadata() { ); } +#[test] +fn guest_generation_compiles_host_managed_capability_methods() { + let dir = TempDir::new().unwrap(); + let target_path = Utf8Path::from_path(dir.path()).unwrap(); + let capability_tuple = SchemaType::tuple(vec![ + SchemaType::secret(Default::default()), + SchemaType::quota_token(Default::default()), + SchemaType::permission_card(Default::default()), + ]); + let envelope = SchemaType::record(vec![named_field( + "capabilities", + SchemaType::list(capability_tuple), + )]); + let capability_modalities = multimodal(vec![ + variant_case("secret", Some(SchemaType::secret(Default::default()))), + variant_case("quota", Some(SchemaType::quota_token(Default::default()))), + variant_case( + "permission", + Some(SchemaType::permission_card(Default::default())), + ), + ]); + let agent_type = agent( + "CapabilityAgent", + "rust", + vec![], + vec![ + method( + "transfer", + vec![field("envelope", ref_to("CapabilityEnvelope"))], + Some(ref_to("CapabilityEnvelope")), + ), + method( + "transferMultimodal", + vec![field("capabilities", capability_modalities.clone())], + Some(capability_modalities), + ), + ], + vec![def("CapabilityEnvelope", envelope)], + AgentMode::Durable, + ); + let mut generator = RustBridgeGenerator::new_with_mode( + agent_type, + target_path, + true, + RustBridgeMode::GuestWasmRpc, + ) + .unwrap(); + generator.generate().unwrap(); + + let lib_rs = std::fs::read_to_string(target_path.join("src/lib.rs")).unwrap(); + for capability_type in [ + "golem_rust::secrets::GuestSecretHandle", + "golem_rust::quota::QuotaToken", + "golem_rust::schema::wit::GuestPermissionCardHandle", + ] { + assert!( + lib_rs.contains(capability_type), + "missing capability type {capability_type}:\n{lib_rs}" + ); + } + assert!(!lib_rs.contains("#[derive(Debug, Clone)]\npub struct CapabilityEnvelope")); + assert!(!lib_rs.contains("#[derive(Debug, Clone)]\npub enum Multimodal0")); + + let shared_target_dir = crate::workspace_path().join("target/shared_bridge_tests"); + let output = std::process::Command::new("cargo") + .arg("check") + .arg("--target-dir") + .arg(shared_target_dir) + .current_dir(target_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated guest capability crate failed cargo check\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn bridge_rust_external_consumer_can_configure_with_only_generated_dependency() { let dir = TempDir::new().unwrap(); diff --git a/cli/golem-cli/tests/bridge_gen/scala.rs b/cli/golem-cli/tests/bridge_gen/scala.rs index 3a3dc4647f..0d15e95358 100644 --- a/cli/golem-cli/tests/bridge_gen/scala.rs +++ b/cli/golem-cli/tests/bridge_gen/scala.rs @@ -902,6 +902,65 @@ fn guest_durable_phantom_constructors_use_replay_safe_identity() { compile_guest_if_enabled(dir.as_path()); } +#[test] +fn guest_generation_compiles_nested_named_and_multimodal_host_managed_capabilities() { + let capability_tuple = SchemaType::tuple(vec![ + SchemaType::secret(Default::default()), + SchemaType::quota_token(Default::default()), + SchemaType::permission_card(Default::default()), + ]); + let envelope = SchemaType::record(vec![named_field( + "capabilities", + SchemaType::list(capability_tuple), + )]); + let capability_modalities = multimodal(vec![ + variant_case("secret", Some(SchemaType::secret(Default::default()))), + variant_case("quota", Some(SchemaType::quota_token(Default::default()))), + variant_case( + "permission", + Some(SchemaType::permission_card(Default::default())), + ), + ]); + let pkg = GeneratedPackage::new_with_mode( + agent( + "CapabilityAgent", + "scala", + vec![], + vec![ + method( + "transfer", + vec![field("envelope", ref_to("capability-envelope"))], + Some(ref_to("capability-envelope")), + ), + method( + "transferMultimodal", + vec![field("capabilities", capability_modalities.clone())], + Some(capability_modalities), + ), + ], + vec![def("capability-envelope", envelope)], + AgentMode::Durable, + ), + ScalaBridgeMode::GuestWasmRpc, + ); + let dir = pkg.package_dir(); + let source = std::fs::read_to_string( + dir.join("src/main/scala/golem/bridge/client/capability_agent/CapabilityAgentClient.scala"), + ) + .unwrap(); + + assert!(source.contains("_root_.golem.schema.GuestSecretHandle")); + assert!(source.contains("_root_.golem.host.QuotaApi.QuotaToken")); + assert!(source.contains("_root_.golem.schema.GuestPermissionCardHandle")); + assert!(source.contains("_root_.golem.schema.SchemaValue.SecretValue(")); + assert!(source.contains("_root_.golem.schema.SchemaValue.QuotaTokenHandle(")); + assert!(source.contains("_root_.golem.schema.SchemaValue.PermissionCardHandle(")); + assert!(source.contains("final case class CapabilityEnvelope(")); + assert!(source.contains("sealed trait Multimodal0")); + + compile_guest_if_enabled(dir.as_path()); +} + /// Generates a bridge for an agent with rich named types (record, enum, /// variant) and checks the emitted Scala definitions. #[test] diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index bdc0aa728f..fb47886a80 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -14,7 +14,8 @@ use crate::bridge_gen::fixtures::{ agent, code_first_snippets_agent_type, def, field, local_config, method, - multi_agent_wrapper_2_types, named_field, ref_to, single_agent_wrapper_types, + multi_agent_wrapper_2_types, multimodal, named_field, ref_to, single_agent_wrapper_types, + variant_case, }; use crate::bridge_gen::scala::grep_tool; use crate::bridge_gen::type_naming::test_type_naming; @@ -768,6 +769,77 @@ fn guest_sdk_native_shapes_generate_direct_codecs_and_compile() { assert!(!source.contains("fromGuestSchemaValue")); } +#[test] +fn guest_generation_compiles_host_managed_capability_methods() { + let dir = TempDir::new().unwrap(); + let target = Utf8Path::from_path(dir.path()).unwrap(); + let capability_tuple = SchemaType::tuple(vec![ + SchemaType::secret(Default::default()), + SchemaType::quota_token(Default::default()), + SchemaType::permission_card(Default::default()), + ]); + let envelope = SchemaType::record(vec![named_field( + "capabilities", + SchemaType::list(capability_tuple), + )]); + let capability_modalities = multimodal(vec![ + variant_case("secret", Some(SchemaType::secret(Default::default()))), + variant_case("quota", Some(SchemaType::quota_token(Default::default()))), + variant_case( + "permission", + Some(SchemaType::permission_card(Default::default())), + ), + ]); + let agent_type = agent( + "CapabilityAgent", + "typescript", + vec![], + vec![ + method( + "transfer", + vec![field("envelope", ref_to("capability-envelope"))], + Some(ref_to("capability-envelope")), + ), + method( + "transferMultimodal", + vec![field("capabilities", capability_modalities.clone())], + Some(capability_modalities), + ), + ], + vec![def("capability-envelope", envelope)], + AgentMode::Durable, + ); + generate_and_compile_with_mode(agent_type, target, TypeScriptBridgeMode::GuestWasmRpc); + + let source = std::fs::read_to_string( + target.join("capability-agent-guest-client/capability-agent-guest-client.ts"), + ) + .unwrap(); + for capability_type in [ + "base.SecretHandle", + "base.QuotaToken", + "base.PermissionCardHandle", + ] { + assert!( + source.contains(capability_type), + "missing generated capability type {capability_type}:\n{source}" + ); + } + for codec in [ + "base.secretHandleToSchemaValue(", + "base.secretHandleFromSchemaValue(", + "base.quotaTokenToSchemaValue(", + "base.quotaTokenFromSchemaValue(", + "base.permissionCardHandleToSchemaValue(", + "base.permissionCardHandleFromSchemaValue(", + ] { + assert!( + source.contains(codec), + "missing generated capability codec {codec}:\n{source}" + ); + } +} + #[test] fn guest_ephemeral_generation_uses_metadata_runtime_calls() { let dir = TempDir::new().unwrap(); diff --git a/golem-client/build.rs b/golem-client/build.rs index 03b812843e..78b84a2569 100644 --- a/golem-client/build.rs +++ b/golem-client/build.rs @@ -126,15 +126,23 @@ fn generate(yaml_path: PathBuf, out_dir: OsString) { // agent secret ( "AgentSecretDto", - "golem_common::model::agent_secret::AgentSecretDto", + "golem_common::model::external_agent_secret::AgentSecretDto", ), ( "AgentSecretCreation", - "golem_common::model::agent_secret::AgentSecretCreation", + "golem_common::model::external_agent_secret::AgentSecretCreation", ), ( "AgentSecretUpdate", - "golem_common::model::agent_secret::AgentSecretUpdate", + "golem_common::model::external_agent_secret::AgentSecretUpdate", + ), + ( + "ExternalSchemaValue", + "golem_common::schema::ExternalSchemaValue", + ), + ( + "ExternalTypedSchemaValue", + "golem_common::schema::ExternalTypedSchemaValue", ), // retry policy ( diff --git a/golem-common/src/base_model/external_agent_secret.rs b/golem-common/src/base_model/external_agent_secret.rs new file mode 100644 index 0000000000..5e3dad6c5c --- /dev/null +++ b/golem-common/src/base_model/external_agent_secret.rs @@ -0,0 +1,104 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::base_model::agent_secret::{ + AgentSecretCreation as DomainAgentSecretCreation, AgentSecretDto as DomainAgentSecretDto, + AgentSecretId, AgentSecretPath, AgentSecretRevision, + AgentSecretUpdate as DomainAgentSecretUpdate, CanonicalAgentSecretPath, +}; +use crate::base_model::environment::EnvironmentId; +use crate::base_model::optional_field_update::OptionalFieldUpdate; +use crate::schema::{ExternalSchemaValue, SchemaGraph}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "full", derive(poem_openapi::Object))] +#[cfg_attr( + feature = "full", + oai(rename = "AgentSecretCreation", rename_all = "camelCase") +)] +#[serde(rename_all = "camelCase")] +pub struct AgentSecretCreation { + pub path: AgentSecretPath, + pub secret_type: SchemaGraph, + pub secret_value: Option, +} + +impl From for DomainAgentSecretCreation { + fn from(value: AgentSecretCreation) -> Self { + Self { + path: value.path, + secret_type: value.secret_type, + secret_value: value.secret_value.map(ExternalSchemaValue::into_inner), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "full", derive(poem_openapi::Object))] +#[cfg_attr( + feature = "full", + oai(rename = "AgentSecretUpdate", rename_all = "camelCase") +)] +#[serde(rename_all = "camelCase")] +pub struct AgentSecretUpdate { + pub current_revision: AgentSecretRevision, + pub secret_value: OptionalFieldUpdate, +} + +impl From for DomainAgentSecretUpdate { + fn from(value: AgentSecretUpdate) -> Self { + Self { + current_revision: value.current_revision, + secret_value: match value.secret_value { + OptionalFieldUpdate::Set(value) => OptionalFieldUpdate::Set(value.into_inner()), + OptionalFieldUpdate::Unset => OptionalFieldUpdate::Unset, + OptionalFieldUpdate::NoChange => OptionalFieldUpdate::NoChange, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "full", derive(poem_openapi::Object))] +#[cfg_attr( + feature = "full", + oai(rename = "AgentSecretDto", rename_all = "camelCase") +)] +#[serde(rename_all = "camelCase")] +pub struct AgentSecretDto { + pub id: AgentSecretId, + pub environment_id: EnvironmentId, + pub path: CanonicalAgentSecretPath, + pub revision: AgentSecretRevision, + pub secret_type: SchemaGraph, + pub secret_value: Option, +} + +impl TryFrom for AgentSecretDto { + type Error = String; + + fn try_from(value: DomainAgentSecretDto) -> Result { + Ok(Self { + id: value.id, + environment_id: value.environment_id, + path: value.path, + revision: value.revision, + secret_type: value.secret_type, + secret_value: value + .secret_value + .map(ExternalSchemaValue::try_from) + .transpose()?, + }) + } +} diff --git a/golem-common/src/base_model/mod.rs b/golem-common/src/base_model/mod.rs index fe77de16f9..9a8fa1125b 100644 --- a/golem-common/src/base_model/mod.rs +++ b/golem-common/src/base_model/mod.rs @@ -34,6 +34,7 @@ pub mod environment; pub mod environment_plugin_grant; pub mod environment_tool_grant; pub mod error; +pub mod external_agent_secret; pub mod http_api_deployment; pub mod invocation_context; pub mod json; diff --git a/golem-common/src/base_model/oplog/public_types.rs b/golem-common/src/base_model/oplog/public_types.rs index 38051c3b9f..d403bf3a6a 100644 --- a/golem-common/src/base_model/oplog/public_types.rs +++ b/golem-common/src/base_model/oplog/public_types.rs @@ -39,6 +39,122 @@ pub struct PublicTypedAgentConfigEntry { pub value: TypedSchemaValue, } +fn redact_typed_value(value: &mut TypedSchemaValue) { + *value = crate::schema::redact_host_managed_typed_value(value.clone()); +} + +impl PublicAgentInvocation { + fn redact_host_managed_values_for_external(&mut self) { + match self { + Self::AgentInitialization(params) => { + redact_typed_value(&mut params.constructor_parameters) + } + Self::AgentMethodInvocation(params) => redact_typed_value(&mut params.function_input), + _ => {} + } + } +} + +impl PublicAgentInvocationResult { + fn redact_host_managed_values_for_external(&mut self) { + match self { + Self::AgentInitialization(params) | Self::AgentMethod(params) => { + redact_typed_value(&mut params.output) + } + _ => {} + } + } +} + +impl PublicOplogEntry { + pub fn redact_host_managed_values_for_external(&mut self) { + match self { + Self::Create(params) => { + for entry in &mut params.local_agent_config { + redact_typed_value(&mut entry.value); + } + } + Self::Start(params) => { + if let Some(value) = &mut params.request { + redact_typed_value(value); + } + } + Self::End(params) => { + if let Some(value) = &mut params.response { + redact_typed_value(value); + } + } + Self::Cancelled(params) => { + if let Some(value) = &mut params.partial { + redact_typed_value(value); + } + } + Self::AgentInvocationStarted(params) => { + params.invocation.redact_host_managed_values_for_external() + } + Self::AgentInvocationFinished(params) => { + params.result.redact_host_managed_values_for_external() + } + Self::HostStreamFrame(params) => redact_typed_value(&mut params.payload), + Self::StreamRegistered(params) => redact_typed_value(&mut params.record), + Self::StreamItems(params) => redact_typed_value(&mut params.record), + Self::StreamEnd(params) => redact_typed_value(&mut params.record), + Self::StreamCancel(params) => redact_typed_value(&mut params.record), + Self::StreamSession(params) => redact_typed_value(&mut params.record), + _ => {} + } + } +} + +#[cfg(test)] +mod host_managed_redaction_tests { + use super::*; + use crate::base_model::oplog::public_oplog_entry::HostStreamFrameParams; + use crate::schema::{ + SchemaGraph, SchemaType, SchemaValue, SecretValuePayload, find_host_managed_value, + }; + use chrono::{TimeZone, Utc}; + use test_r::test; + + fn secret() -> TypedSchemaValue { + TypedSchemaValue::new( + SchemaGraph::anonymous(SchemaType::secret(Default::default())), + SchemaValue::Secret(SecretValuePayload { + secret_id: uuid::Uuid::nil(), + config_key: Some(vec!["credential".to_string()]), + version: 7, + resolved_at: Utc.timestamp_opt(0, 0).unwrap(), + category: None, + }), + ) + } + + #[test] + fn public_oplog_redacts_host_managed_stream_payloads() { + let mut entry = PublicOplogEntry::HostStreamFrame(HostStreamFrameParams { + timestamp: Timestamp::now_utc(), + parent_start_index: OplogIndex::INITIAL, + kind: HostStreamKind::P3HttpRequestBody, + payload: secret(), + }); + + entry.redact_host_managed_values_for_external(); + + let PublicOplogEntry::HostStreamFrame(parameters) = entry else { + unreachable!() + }; + assert!(find_host_managed_value(parameters.payload.value()).is_none()); + assert!(matches!( + parameters.payload.value(), + SchemaValue::String(value) if value == "" + )); + assert!(matches!( + parameters.payload.root_type(), + SchemaType::String { .. } + )); + } +} + #[cfg(feature = "full")] impl TryFrom for crate::base_model::worker::UntypedAgentConfigEntry { type Error = String; diff --git a/golem-common/src/model/agent/structural_format/mod.rs b/golem-common/src/model/agent/structural_format/mod.rs index 154a524a0f..43e825bd61 100644 --- a/golem-common/src/model/agent/structural_format/mod.rs +++ b/golem-common/src/model/agent/structural_format/mod.rs @@ -26,9 +26,7 @@ use crate::model::agent::text_utils::{ write_json_escaped, write_json_escaped_char, write_with_decimal_point, }; -use crate::schema::canonical::{ - datetime, duration, permission_card, quantity, quota_token, secret, -}; +use crate::schema::canonical::{datetime, duration, quantity}; use crate::schema::graph::{SchemaGraph, TypedSchemaValue}; use crate::schema::schema_type::{SchemaType, UnionSpec}; use crate::schema::schema_value::{ @@ -51,6 +49,8 @@ pub enum StructuralFormatError { RejectedFloat, #[error("Handle types cannot be serialized to agent IDs")] HandleType, + #[error("Host-managed capability types cannot be serialized to agent IDs")] + HostManagedCapability, #[error("Max nesting depth ({0}) exceeded")] MaxDepthExceeded(usize), #[error("Parse error at position {position}: {message}")] @@ -488,19 +488,12 @@ fn format_schema_value( (SchemaValue::Quantity(v), SchemaType::Quantity { .. }) => { format_tagged_string(buf, "qty", &quantity::to_text(v).map_err(canonical_err)?) } - (SchemaValue::Secret(v), SchemaType::Secret { .. }) => { - format_tagged_string(buf, "secret", &secret::to_text(v).map_err(canonical_err)?) - } - (SchemaValue::QuotaToken(v), SchemaType::QuotaToken { .. }) => { - format_tagged_string(buf, "qt", "a_token::to_text(v).map_err(canonical_err)?) - } - (SchemaValue::PermissionCard(v), SchemaType::PermissionCard { .. }) => { - format_tagged_string( - buf, - "pc", - &permission_card::to_text(v).map_err(canonical_err)?, - ) - } + ( + _, + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. }, + ) => return Err(StructuralFormatError::HostManagedCapability), (SchemaValue::Union(UnionValuePayload { tag, body }), SchemaType::Union { spec, .. }) => { let (idx, branch) = union_branch(spec, tag)?; write!(buf, "u{idx}").unwrap(); @@ -794,18 +787,11 @@ impl<'a> Parser<'a> { quantity::from_text(&self.parse_tagged_string("qty")?) .map_err(|e| self.error(&format!("Invalid quantity: {e}")))?, )), - SchemaType::Secret { .. } => Ok(SchemaValue::Secret( - secret::from_text(&self.parse_tagged_string("secret")?) - .map_err(|e| self.error(&format!("Invalid secret: {e}")))?, - )), - SchemaType::QuotaToken { .. } => Ok(SchemaValue::QuotaToken( - quota_token::from_text(&self.parse_tagged_string("qt")?) - .map_err(|e| self.error(&format!("Invalid quota token: {e}")))?, - )), - SchemaType::PermissionCard { .. } => Ok(SchemaValue::PermissionCard( - permission_card::from_text(&self.parse_tagged_string("pc")?) - .map_err(|e| self.error(&format!("Invalid permission card: {e}")))?, - )), + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } => { + Err(StructuralFormatError::HostManagedCapability) + } SchemaType::Union { spec, .. } => self.parse_schema_union(spec, graph, depth), SchemaType::Future { .. } | SchemaType::Stream { .. } => { Err(StructuralFormatError::HandleType) diff --git a/golem-common/src/model/agent/structural_format/tests.rs b/golem-common/src/model/agent/structural_format/tests.rs index a5fe028364..162c14319b 100644 --- a/golem-common/src/model/agent/structural_format/tests.rs +++ b/golem-common/src/model/agent/structural_format/tests.rs @@ -24,15 +24,14 @@ mod schema_native_tests { use crate::schema::metadata::MetadataEnvelope; use crate::schema::schema_type::{ BinaryRestrictions, DiscriminatorRule, NamedFieldType, PathDirection, PathKind, PathSpec, - QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType, SecretSpec, - TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType, + QuantitySpec, QuantityValue, ResultSpec, SchemaType, TextRestrictions, UnionBranch, + UnionSpec, UrlRestrictions, VariantCaseType, }; use crate::schema::schema_value::{ - BinaryValuePayload, DurationValuePayload, QuotaTokenValuePayload, ResultValuePayload, - SchemaValue, SecretValuePayload, TextValuePayload, UnionValuePayload, VariantValuePayload, + BinaryValuePayload, DurationValuePayload, ResultValuePayload, SchemaValue, + SecretValuePayload, TextValuePayload, UnionValuePayload, VariantValuePayload, }; use chrono::{TimeZone, Utc}; - use golem_schema::EnvironmentId; use pretty_assertions::assert_eq; use test_r::test; use uuid::Uuid; @@ -288,13 +287,6 @@ mod schema_native_tests { #[test] fn rich_values_and_map_roundtrip() { let dt = Utc.with_ymd_and_hms(2025, 4, 12, 13, 14, 15).unwrap(); - let quota = QuotaTokenValuePayload { - environment_id: EnvironmentId { uuid: Uuid::nil() }, - resource_name: "res".into(), - expected_use: 5, - last_credit: -2, - last_credit_at: dt, - }; let v = typed( vec![ ("text", SchemaType::text(TextRestrictions::default())), @@ -325,8 +317,6 @@ mod schema_native_tests { max: None, }), ), - ("secret", SchemaType::secret(SecretSpec::default())), - ("quota", SchemaType::quota_token(QuotaTokenSpec::default())), ( "map", SchemaType::map(SchemaType::string(), SchemaType::u32()), @@ -364,14 +354,6 @@ mod schema_native_tests { scale: 1, unit: "kg".into(), }), - SchemaValue::Secret(SecretValuePayload { - secret_id: Uuid::parse_str("00000000-0000-0000-0000-000000000123").unwrap(), - config_key: Some(vec!["secret".to_string(), "ref".to_string()]), - version: 7, - resolved_at: dt, - category: Some("api-key".to_string()), - }), - SchemaValue::QuotaToken(quota), SchemaValue::Map { entries: vec![ (SchemaValue::String("a".into()), SchemaValue::U32(1)), @@ -381,15 +363,33 @@ mod schema_native_tests { ], ); let formatted = roundtrip(&v); - assert!(formatted.starts_with("@t\"hello\",@t[hu]\"szia\",@b[]\"AQID\",@b[text/plain]\"YWJj\",@p\"/tmp/a b\",@u\"https://example.com/a?b=c\",@dt\"2025-04-12T13:14:15.000000000Z\",@dur\"PT1.5S\",@qty\"12.3kg\",@secret\"secret:{")); - assert!( - formatted.contains("\\\"secretId\\\":\\\"00000000-0000-0000-0000-000000000123\\\"") - ); - assert!(formatted.contains("\\\"configKey\\\":[\\\"secret\\\",\\\"ref\\\"]")); - assert!(formatted.contains("\\\"version\\\":7")); + assert!(formatted.starts_with("@t\"hello\",@t[hu]\"szia\",@b[]\"AQID\",@b[text/plain]\"YWJj\",@p\"/tmp/a b\",@u\"https://example.com/a?b=c\",@dt\"2025-04-12T13:14:15.000000000Z\",@dur\"PT1.5S\",@qty\"12.3kg\"")); assert!(formatted.ends_with(",m[(\"a\",1),(\"b\",2)]")); } + #[test] + fn host_managed_capabilities_are_rejected_in_agent_ids() { + let value = typed( + vec![("secret", SchemaType::secret(Default::default()))], + vec![SchemaValue::Secret(SecretValuePayload { + secret_id: Uuid::nil(), + config_key: None, + version: 1, + resolved_at: Utc::now(), + category: None, + })], + ); + + assert_eq!( + format_structural_typed(&value), + Err(StructuralFormatError::HostManagedCapability) + ); + assert_eq!( + parse_structural_typed("@secret\"forged\"", value.graph(), value.root_type()), + Err(StructuralFormatError::HostManagedCapability) + ); + } + #[test] fn secret_reveal_payloads_roundtrip_and_format() { let secret_id = Uuid::parse_str("00000000-0000-0000-0000-000000000123").unwrap(); diff --git a/golem-common/src/model/component_metadata.rs b/golem-common/src/model/component_metadata.rs index 36a870463a..9128340750 100644 --- a/golem-common/src/model/component_metadata.rs +++ b/golem-common/src/model/component_metadata.rs @@ -142,6 +142,15 @@ impl ComponentMetadata { } } + pub fn redact_host_managed_values_for_external(&mut self) { + let data = Arc::make_mut(&mut self.data); + for provision in data.agent_type_provision_configs.values_mut() { + for entry in &mut provision.config { + entry.value = crate::schema::redact_host_managed_typed_value(entry.value.clone()); + } + } + } + /// Returns a new `ComponentMetadata` with its tools replaced. /// All component analysis and agent metadata is preserved. pub fn with_tools(&self, tools: BTreeMap) -> Self { diff --git a/golem-common/src/model/worker.rs b/golem-common/src/model/worker.rs index bb46fcb911..b5bb425428 100644 --- a/golem-common/src/model/worker.rs +++ b/golem-common/src/model/worker.rs @@ -49,6 +49,14 @@ impl TypedAgentConfigEntry { } } +impl AgentMetadataDto { + pub fn redact_host_managed_values_for_external(&mut self) { + for entry in &mut self.config { + entry.value = crate::schema::redact_host_managed_typed_value(entry.value.clone()); + } + } +} + impl UntypedAgentConfigEntry { pub fn enrich_with_type( self, diff --git a/golem-common/src/schema/external.rs b/golem-common/src/schema/external.rs new file mode 100644 index 0000000000..8d0f5cc2e0 --- /dev/null +++ b/golem-common/src/schema/external.rs @@ -0,0 +1,370 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::schema::{SchemaValue, TypedSchemaValue, find_host_managed_value}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Canonical schema-value JSON that cannot contain a host-managed capability. +#[derive(Clone, Debug, PartialEq)] +pub struct ExternalSchemaValue(SchemaValue); + +impl ExternalSchemaValue { + pub fn as_inner(&self) -> &SchemaValue { + &self.0 + } + + pub fn into_inner(self) -> SchemaValue { + self.0 + } +} + +impl TryFrom for ExternalSchemaValue { + type Error = String; + + fn try_from(value: SchemaValue) -> Result { + match find_host_managed_value(&value) { + Some(occurrence) => Err(format!( + "host-managed capability `{}` cannot cross an external JSON boundary ({})", + occurrence.kind.kind_name(), + occurrence.path + )), + None => Ok(Self(value)), + } + } +} + +impl From for SchemaValue { + fn from(value: ExternalSchemaValue) -> Self { + value.into_inner() + } +} + +impl Serialize for ExternalSchemaValue { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ExternalSchemaValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = SchemaValue::deserialize(deserializer)?; + Self::try_from(value).map_err(serde::de::Error::custom) + } +} + +/// A typed canonical value whose value tree is safe for external JSON. +#[derive(Clone, Debug, PartialEq)] +pub struct ExternalTypedSchemaValue(TypedSchemaValue); + +impl ExternalTypedSchemaValue { + pub fn as_inner(&self) -> &TypedSchemaValue { + &self.0 + } + + pub fn into_inner(self) -> TypedSchemaValue { + self.0 + } +} + +impl TryFrom for ExternalTypedSchemaValue { + type Error = String; + + fn try_from(value: TypedSchemaValue) -> Result { + match find_host_managed_value(value.value()) { + Some(occurrence) => Err(format!( + "host-managed capability `{}` cannot cross an external JSON boundary ({})", + occurrence.kind.kind_name(), + occurrence.path + )), + None => Ok(Self(value)), + } + } +} + +impl From for TypedSchemaValue { + fn from(value: ExternalTypedSchemaValue) -> Self { + value.into_inner() + } +} + +impl Serialize for ExternalTypedSchemaValue { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ExternalTypedSchemaValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = TypedSchemaValue::deserialize(deserializer)?; + Self::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "full")] +mod poem_impl { + use super::{ExternalSchemaValue, ExternalTypedSchemaValue}; + use crate::schema::{SchemaValue, TypedSchemaValue}; + use poem_openapi::registry::{MetaSchemaRef, Registry}; + use poem_openapi::types::{ParseError, ParseFromJSON, ParseResult, ToJSON, Type}; + use serde_json::Value; + use std::borrow::Cow; + + #[allow(dead_code)] + mod openapi_schema { + use crate::schema::{ + BinaryValuePayload, DurationValuePayload, QuantityValue, SchemaGraph, TextValuePayload, + }; + use chrono::{DateTime, Utc}; + + #[derive(serde::Serialize, serde::Deserialize, golem_schema_derive::PoemSchema)] + #[serde(tag = "kind", content = "value", rename_all = "kebab-case")] + pub enum ExternalSchemaValue { + Bool(bool), + S8(i8), + S16(i16), + S32(i32), + S64(i64), + U8(u8), + U16(u16), + U32(u32), + U64(u64), + F32(f32), + F64(f64), + Char(char), + String(String), + Record { + fields: Vec, + }, + Variant(ExternalVariantValuePayload), + Enum { + case: u32, + }, + Flags { + bits: Vec, + }, + Tuple { + elements: Vec, + }, + List { + elements: Vec, + }, + FixedList { + elements: Vec, + }, + Map { + entries: Vec<(ExternalSchemaValue, ExternalSchemaValue)>, + }, + Option { + inner: Option>, + }, + Result(ExternalResultValuePayload), + Text(TextValuePayload), + Binary(BinaryValuePayload), + Path { + path: String, + }, + Url { + url: String, + }, + Datetime { + value: DateTime, + }, + Duration(DurationValuePayload), + Quantity(QuantityValue), + Union(ExternalUnionValuePayload), + } + + #[derive(serde::Serialize, serde::Deserialize, golem_schema_derive::PoemSchema)] + #[serde(rename_all = "camelCase")] + pub struct ExternalVariantValuePayload { + pub case: u32, + pub payload: Option>, + } + + #[derive(serde::Serialize, serde::Deserialize, golem_schema_derive::PoemSchema)] + #[serde(tag = "tag", rename_all = "kebab-case")] + pub enum ExternalResultValuePayload { + Ok { + value: Option>, + }, + Err { + value: Option>, + }, + } + + #[derive(serde::Serialize, serde::Deserialize, golem_schema_derive::PoemSchema)] + #[serde(rename_all = "camelCase")] + pub struct ExternalUnionValuePayload { + pub tag: String, + pub body: Box, + } + + #[derive(serde::Serialize, serde::Deserialize, golem_schema_derive::PoemSchema)] + #[serde(rename_all = "camelCase")] + pub struct ExternalTypedSchemaValue { + pub graph: SchemaGraph, + pub value: ExternalSchemaValue, + } + } + + macro_rules! impl_external_poem_type { + ($external:ty, $inner:ty, $schema:ty, $name:literal) => { + impl Type for $external { + const IS_REQUIRED: bool = true; + type RawValueType = Self; + type RawElementValueType = Self; + + fn name() -> Cow<'static, str> { + $name.into() + } + + fn schema_ref() -> MetaSchemaRef { + <$schema as Type>::schema_ref() + } + + fn register(registry: &mut Registry) { + <$schema as Type>::register(registry); + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(std::iter::once(self)) + } + } + + impl ParseFromJSON for $external { + fn parse_from_json(value: Option) -> ParseResult { + <$inner as ParseFromJSON>::parse_from_json(value) + .map_err(ParseError::propagate) + .and_then(|value| Self::try_from(value).map_err(ParseError::custom)) + } + } + + impl ToJSON for $external { + fn to_json(&self) -> Option { + self.as_inner().to_json() + } + } + }; + } + + impl_external_poem_type!( + ExternalSchemaValue, + SchemaValue, + openapi_schema::ExternalSchemaValue, + "ExternalSchemaValue" + ); + impl_external_poem_type!( + ExternalTypedSchemaValue, + TypedSchemaValue, + openapi_schema::ExternalTypedSchemaValue, + "ExternalTypedSchemaValue" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{QuotaTokenValuePayload, SchemaGraph}; + use chrono::{TimeZone, Utc}; + use test_r::test; + + fn forged_quota_token() -> SchemaValue { + SchemaValue::QuotaToken(QuotaTokenValuePayload { + environment_id: golem_schema::EnvironmentId::new(uuid::Uuid::nil()), + resource_name: "forged".to_string(), + expected_use: 100, + last_credit: 100, + last_credit_at: Utc.timestamp_opt(0, 0).unwrap(), + }) + } + + #[test] + fn canonical_external_value_rejects_nested_capabilities() { + let value = SchemaValue::Record { + fields: vec![SchemaValue::Option { + inner: Some(Box::new(forged_quota_token())), + }], + }; + let json = serde_json::to_value(value).unwrap(); + + let error = serde_json::from_value::(json).unwrap_err(); + assert!(error.to_string().contains("quota-token")); + } + + #[test] + fn typed_external_value_rejects_capabilities_but_preserves_ordinary_values() { + let forged = TypedSchemaValue::new( + SchemaGraph::anonymous(crate::schema::SchemaType::quota_token(Default::default())), + forged_quota_token(), + ); + assert!(ExternalTypedSchemaValue::try_from(forged).is_err()); + + let ordinary = TypedSchemaValue::new( + SchemaGraph::anonymous(crate::schema::SchemaType::string()), + SchemaValue::String("safe".to_string()), + ); + let external = ExternalTypedSchemaValue::try_from(ordinary.clone()).unwrap(); + assert_eq!(external.into_inner(), ordinary); + } + + #[cfg(feature = "full")] + #[test] + fn external_openapi_schema_is_recursive_without_capability_value_variants() { + use poem_openapi::registry::Registry; + use poem_openapi::types::Type; + + let mut registry = Registry::new(); + ExternalTypedSchemaValue::register(&mut registry); + let value_schema = serde_json::to_string( + registry + .schemas + .get("ExternalSchemaValue") + .expect("external value schema must be registered"), + ) + .unwrap(); + let typed_schema = serde_json::to_string( + registry + .schemas + .get("ExternalTypedSchemaValue") + .expect("external typed-value schema must be registered"), + ) + .unwrap(); + + assert!(value_schema.contains("ExternalSchemaValue")); + assert!(!value_schema.contains("SecretValuePayload")); + assert!(!value_schema.contains("QuotaTokenValuePayload")); + assert!(!value_schema.contains("PermissionCardValuePayload")); + assert!(typed_schema.contains("ExternalSchemaValue")); + assert!(registry.schemas.contains_key("SchemaType")); + assert!(registry.schemas.contains_key("SecretSpec")); + } +} diff --git a/golem-common/src/schema/mod.rs b/golem-common/src/schema/mod.rs index 80be29282d..640ea9dd9a 100644 --- a/golem-common/src/schema/mod.rs +++ b/golem-common/src/schema/mod.rs @@ -21,6 +21,7 @@ pub mod agent; mod common_impls; +pub mod external; #[cfg(feature = "full")] pub mod protobuf; pub mod public_json; @@ -54,10 +55,12 @@ pub use conversion::{ Quantity, QuantityUnit, SchemaBuilder, merge_agent_graphs, try_into_schema_graph, try_into_typed_schema_value, }; +pub use external::{ExternalSchemaValue, ExternalTypedSchemaValue}; pub use golem_schema_derive::{FromSchema, IntoSchema}; pub use graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue}; pub use host_managed::{ - HostManagedKind, RedactedSchemaValue, redact_host_managed_type, + HostManagedKind, HostManagedOccurrence, HostManagedTraversalError, RedactedSchemaValue, + find_host_managed_type, find_host_managed_value, redact_host_managed_type, redact_host_managed_typed_value, redact_host_managed_value, redacted_schema_value_debug, }; pub use metadata::{MetadataEnvelope, Role, TypeId}; diff --git a/golem-common/src/schema/render/json_schema.rs b/golem-common/src/schema/render/json_schema.rs index 86ecfbd81c..2c5085cd59 100644 --- a/golem-common/src/schema/render/json_schema.rs +++ b/golem-common/src/schema/render/json_schema.rs @@ -31,14 +31,20 @@ const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$"; /// Configuration for the JSON Schema renderer. /// -/// The renderer always produces the same canonical structural document for -/// every consumer; the only knob is whether to emit the `$schema` draft -/// marker at the document root (consumers that embed the schema elsewhere, -/// such as tool/resource schemas, omit it). +/// The public constants select the trusted canonical representation; boundary +/// renderers additionally select their host-managed capability policy. #[derive(Clone, Copy, Debug)] pub struct JsonSchemaConfig { /// Emit the `$schema` JSON Schema draft marker at the document root. pub include_draft_marker: bool, + host_managed: HostManagedSchemaPolicy, +} + +#[derive(Clone, Copy, Debug)] +enum HostManagedSchemaPolicy { + TrustedSnapshot, + Reject, + Redact, } impl JsonSchemaConfig { @@ -46,12 +52,24 @@ impl JsonSchemaConfig { /// draft marker). pub const CANONICAL: Self = Self { include_draft_marker: true, + host_managed: HostManagedSchemaPolicy::TrustedSnapshot, }; /// Canonical JSON Schema document without the `$schema` draft marker, for /// consumers that embed the schema elsewhere (e.g. tool/resource schemas). pub const WITHOUT_DRAFT_MARKER: Self = Self { include_draft_marker: false, + host_managed: HostManagedSchemaPolicy::TrustedSnapshot, + }; + + pub(crate) const EXTERNAL_INPUT: Self = Self { + include_draft_marker: false, + host_managed: HostManagedSchemaPolicy::Reject, + }; + + pub(crate) const EXTERNAL_OUTPUT: Self = Self { + include_draft_marker: false, + host_managed: HostManagedSchemaPolicy::Redact, }; } @@ -119,6 +137,8 @@ pub fn to_json_schema_with_config( /// This reuses the same node rendering as [`to_json_schema_with_config`] by /// projecting the user-supplied parameter list onto a synthetic record root; /// the record renderer already emits an option-aware `required` array. +/// Host-managed capability leaves are unsatisfiable because external callers +/// cannot construct them. pub fn input_schema_to_json_schema( graph: &SchemaGraph, input: &InputSchema, @@ -141,13 +161,22 @@ pub fn input_schema_to_json_schema( fields: record_fields, metadata: MetadataEnvelope::default(), }; - to_json_schema_with_config(graph, &record, config) + to_json_schema_with_config( + graph, + &record, + JsonSchemaConfig { + include_draft_marker: config.include_draft_marker, + ..JsonSchemaConfig::EXTERNAL_INPUT + }, + ) } /// Render an [`OutputSchema`] to an optional JSON Schema document. /// /// `OutputSchema::Unit` renders to `None` (the method has no return value). -/// `OutputSchema::Single(ty)` renders `ty` via [`to_json_schema_with_config`]. +/// `OutputSchema::Single(ty)` renders `ty` via [`to_json_schema_with_config`], +/// with host-managed capability leaves represented by their redacted external +/// placeholder. /// /// This renderer applies no protocol policy: it does **not** suppress /// multimodal outputs. Consumers that omit `outputSchema` for multimodal @@ -159,7 +188,14 @@ pub fn output_schema_to_json_schema( ) -> Option { match output { OutputSchema::Unit => None, - OutputSchema::Single(ty) => Some(to_json_schema_with_config(graph, ty, config)), + OutputSchema::Single(ty) => Some(to_json_schema_with_config( + graph, + ty, + JsonSchemaConfig { + include_draft_marker: config.include_draft_marker, + ..JsonSchemaConfig::EXTERNAL_OUTPUT + }, + )), } } @@ -789,9 +825,21 @@ pub(super) fn render_type( SchemaType::Union { spec, .. } => Value::Object(union_schema(graph, spec, table, config)), - SchemaType::Secret { spec, .. } => Value::Object(secret_schema(spec)), - SchemaType::QuotaToken { spec, .. } => Value::Object(quota_token_schema(spec)), - SchemaType::PermissionCard { spec, .. } => Value::Object(permission_card_schema(spec)), + SchemaType::Secret { spec, .. } => { + host_managed_schema(config.host_managed, "secret", || { + Value::Object(secret_schema(spec)) + }) + } + SchemaType::QuotaToken { spec, .. } => { + host_managed_schema(config.host_managed, "quota-token", || { + Value::Object(quota_token_schema(spec)) + }) + } + SchemaType::PermissionCard { spec, .. } => { + host_managed_schema(config.host_managed, "permission-card", || { + Value::Object(permission_card_schema(spec)) + }) + } SchemaType::Future { .. } | SchemaType::Stream { .. } => obj([ ("type", Value::String("null".to_string())), @@ -810,6 +858,35 @@ pub(super) fn render_type( rendered } +fn host_managed_schema( + policy: HostManagedSchemaPolicy, + kind: &str, + trusted: impl FnOnce() -> Value, +) -> Value { + match policy { + HostManagedSchemaPolicy::TrustedSnapshot => trusted(), + HostManagedSchemaPolicy::Reject => obj([ + ("not", Value::Object(Map::new())), + ( + "description", + Value::String(format!( + "Host-managed {kind} capabilities cannot be supplied externally" + )), + ), + ]), + HostManagedSchemaPolicy::Redact => obj([ + ("type", Value::String("string".to_string())), + ("const", Value::String(format!(""))), + ( + "description", + Value::String(format!( + "Host-managed {kind} capability values are redacted" + )), + ), + ]), + } +} + fn ref_pointer(id: &TypeId, _root: bool) -> String { ref_to_def_key(&id.0) } diff --git a/golem-common/src/schema/render/json_value.rs b/golem-common/src/schema/render/json_value.rs index e992d4859a..e39f56a494 100644 --- a/golem-common/src/schema/render/json_value.rs +++ b/golem-common/src/schema/render/json_value.rs @@ -79,10 +79,37 @@ pub fn from_json_value( graph: &SchemaGraph, ty: &SchemaType, json: &Value, +) -> Result { + from_json_value_with_policy(graph, ty, json, JsonDecodePolicy::Trusted) +} + +/// Decode externally supplied JSON without allowing it to construct +/// host-managed capabilities. Rejection happens when the selected value path +/// reaches a capability leaf, so absent options and unselected sum-type arms +/// remain valid. +pub fn from_untrusted_json_value( + graph: &SchemaGraph, + ty: &SchemaType, + json: &Value, +) -> Result { + from_json_value_with_policy(graph, ty, json, JsonDecodePolicy::Untrusted) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum JsonDecodePolicy { + Trusted, + Untrusted, +} + +fn from_json_value_with_policy( + graph: &SchemaGraph, + ty: &SchemaType, + json: &Value, + policy: JsonDecodePolicy, ) -> Result { let mut path = PathStack::new(); let mut visited: HashSet = HashSet::new(); - from_json_inner(graph, ty, json, &mut path, &mut visited) + from_json_inner(graph, ty, json, &mut path, &mut visited, policy) } // --------------------------------------------------------------------- to @@ -447,11 +474,12 @@ fn from_json_inner( json: &Value, path: &mut PathStack, visited: &mut HashSet, + policy: JsonDecodePolicy, ) -> Result { // Route through the shared ref-resolution helper so the decoder uses // the same cycle protection as the walker-based encoder. let res = resolve_ref::<_, SchemaValue, RenderError>(graph, ty, visited, |graph, body| { - match from_json_body(graph, body, json, path, &mut HashSet::new()) { + match from_json_body(graph, body, json, path, &mut HashSet::new(), policy) { Ok(v) => Ok(v), Err(e) => Err(WalkerError::Walker(e)), } @@ -483,12 +511,25 @@ fn from_json_body( json: &Value, path: &mut PathStack, _local_visited: &mut HashSet, + policy: JsonDecodePolicy, ) -> Result { // The `visited` HashSet for nested references is provided by the caller // through `resolve_ref`. For sub-recursions we re-enter `from_json_inner` // with a fresh set per sibling traversal because ref protection is // scoped to the active stack of references, not the entire walk. let mut visited: HashSet = HashSet::new(); + if policy == JsonDecodePolicy::Untrusted + && let Some(kind) = HostManagedKind::from_type(ty) + { + return Err(mismatch( + path, + format!( + "host-managed capability `{}` cannot be constructed from external JSON", + kind.kind_name() + ), + )); + } + match ty { SchemaType::Ref { .. } => unreachable!("ref resolved by resolve_ref"), SchemaType::Bool { .. } => match json.as_bool() { @@ -616,7 +657,7 @@ fn from_json_body( mismatch(path, format!("missing record field `{}`", field.name)) })?; path.push(PathSegment::Field(field.name.clone())); - let v = from_json_inner(graph, &field.body, value, path, &mut visited)?; + let v = from_json_inner(graph, &field.body, value, path, &mut visited, policy)?; path.pop(); out.push(v); } @@ -649,7 +690,8 @@ fn from_json_body( ) })?; path.push(PathSegment::Variant(name.clone())); - let inner = from_json_inner(graph, payload_ty, payload_json, path, &mut visited)?; + let inner = + from_json_inner(graph, payload_ty, payload_json, path, &mut visited, policy)?; path.pop(); Ok(SchemaValue::Variant(VariantValuePayload { case: idx as u32, @@ -715,7 +757,7 @@ fn from_json_body( let mut out = Vec::with_capacity(arr.len()); for (i, (et, ev)) in elements.iter().zip(arr.iter()).enumerate() { path.push(PathSegment::Index(i)); - let v = from_json_inner(graph, et, ev, path, &mut visited)?; + let v = from_json_inner(graph, et, ev, path, &mut visited, policy)?; path.pop(); out.push(v); } @@ -729,7 +771,7 @@ fn from_json_body( let mut out = Vec::with_capacity(arr.len()); for (i, ev) in arr.iter().enumerate() { path.push(PathSegment::Index(i)); - let v = from_json_inner(graph, element, ev, path, &mut visited)?; + let v = from_json_inner(graph, element, ev, path, &mut visited, policy)?; path.pop(); out.push(v); } @@ -755,7 +797,7 @@ fn from_json_body( let mut out = Vec::with_capacity(arr.len()); for (i, ev) in arr.iter().enumerate() { path.push(PathSegment::Index(i)); - let v = from_json_inner(graph, element, ev, path, &mut visited)?; + let v = from_json_inner(graph, element, ev, path, &mut visited, policy)?; path.pop(); out.push(v); } @@ -781,10 +823,10 @@ fn from_json_body( )); } path.push(PathSegment::MapKey(i)); - let k = from_json_inner(graph, key, &pair[0], path, &mut visited)?; + let k = from_json_inner(graph, key, &pair[0], path, &mut visited, policy)?; path.pop(); path.push(PathSegment::MapValue(i)); - let v = from_json_inner(graph, value, &pair[1], path, &mut visited)?; + let v = from_json_inner(graph, value, &pair[1], path, &mut visited, policy)?; path.pop(); out.push((k, v)); } @@ -795,7 +837,7 @@ fn from_json_body( Value::Null => Ok(SchemaValue::Option { inner: None }), other => { path.push(PathSegment::OptionInner); - let v = from_json_inner(graph, inner, other, path, &mut visited)?; + let v = from_json_inner(graph, inner, other, path, &mut visited, policy)?; path.pop(); Ok(SchemaValue::Option { inner: Some(Box::new(v)), @@ -803,9 +845,11 @@ fn from_json_body( } }, - SchemaType::Result { spec, .. } => decode_result(graph, spec, json, path, &mut visited), + SchemaType::Result { spec, .. } => { + decode_result(graph, spec, json, path, &mut visited, policy) + } - SchemaType::Union { spec, .. } => decode_union(graph, spec, json, path), + SchemaType::Union { spec, .. } => decode_union(graph, spec, json, path, policy), SchemaType::Future { .. } | SchemaType::Stream { .. } => Err(RenderError::Unsupported( "future/stream values have no JSON representation", @@ -819,6 +863,7 @@ fn decode_result( json: &Value, path: &mut PathStack, visited: &mut HashSet, + policy: JsonDecodePolicy, ) -> Result { let obj = json .as_object() @@ -836,7 +881,7 @@ fn decode_result( (None, Value::Null) => None, (Some(ok_ty), other) => { path.push(PathSegment::Ok); - let v = from_json_inner(graph, ok_ty, other, path, visited)?; + let v = from_json_inner(graph, ok_ty, other, path, visited, policy)?; path.pop(); Some(Box::new(v)) } @@ -854,7 +899,7 @@ fn decode_result( (None, Value::Null) => None, (Some(err_ty), other) => { path.push(PathSegment::Err); - let v = from_json_inner(graph, err_ty, other, path, visited)?; + let v = from_json_inner(graph, err_ty, other, path, visited, policy)?; path.pop(); Some(Box::new(v)) } @@ -876,6 +921,7 @@ fn decode_union( spec: &UnionSpec, json: &Value, path: &mut PathStack, + policy: JsonDecodePolicy, ) -> Result { // First: find every branch whose discriminator rule matches the // incoming JSON value. Validation rules out multi-match at construction @@ -891,7 +937,8 @@ fn decode_union( [branch] => { // Then: decode the body against the matched branch. path.push(PathSegment::Union(branch.tag.clone())); - let body = from_json_inner(graph, &branch.body, json, path, &mut HashSet::new())?; + let body = + from_json_inner(graph, &branch.body, json, path, &mut HashSet::new(), policy)?; path.pop(); Ok(SchemaValue::Union(UnionValuePayload { tag: branch.tag.clone(), diff --git a/golem-common/src/schema/render/mod.rs b/golem-common/src/schema/render/mod.rs index e7b6bb19af..de8c7eae53 100644 --- a/golem-common/src/schema/render/mod.rs +++ b/golem-common/src/schema/render/mod.rs @@ -37,6 +37,11 @@ pub use json_schema::{ JsonSchemaConfig, input_schema_to_json_schema, output_schema_to_json_schema, to_json_schema, to_json_schema_with_config, }; -pub use json_value::{from_json_value, to_json_value, to_json_value_redacted}; -pub use openapi::to_openapi_components; +pub use json_value::{ + from_json_value, from_untrusted_json_value, to_json_value, to_json_value_redacted, +}; +pub use openapi::{ + to_external_input_openapi_components, to_external_output_openapi_components, + to_openapi_components, +}; pub use walker::{SchemaWalker, WalkerError, resolve_ref, walk}; diff --git a/golem-common/src/schema/render/openapi.rs b/golem-common/src/schema/render/openapi.rs index b3f93e02b8..ec51315ef6 100644 --- a/golem-common/src/schema/render/openapi.rs +++ b/golem-common/src/schema/render/openapi.rs @@ -28,12 +28,10 @@ use crate::schema::render::json_schema::{ use crate::schema::schema_type::SchemaType; use serde_json::{Map, Value}; -/// JSON Schema renderer configuration used by [`to_openapi_components`]: -/// canonical node shapes, but without the JSON Schema `$schema` draft marker -/// (OpenAPI does not accept it). -const OPENAPI_CONFIG: JsonSchemaConfig = JsonSchemaConfig { - include_draft_marker: false, -}; +/// Canonical trusted JSON Schema renderer configuration used by +/// [`to_openapi_components`], without the `$schema` draft marker (OpenAPI does +/// not accept it). +const OPENAPI_CONFIG: JsonSchemaConfig = JsonSchemaConfig::WITHOUT_DRAFT_MARKER; /// Render `(graph, ty)` to an OpenAPI 3.1 schema bundle. /// @@ -51,11 +49,42 @@ const OPENAPI_CONFIG: JsonSchemaConfig = JsonSchemaConfig { /// OpenAPI does not accept the JSON Schema `$schema` keyword, so it is /// never emitted here. pub fn to_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value { + to_openapi_components_with_config(graph, ty, OPENAPI_CONFIG, None) +} + +/// Render a schema bundle for untrusted external input. +/// +/// Host-managed capability leaves are unsatisfiable, while their type metadata +/// remains present in the surrounding schema structure. +pub fn to_external_input_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value { + to_openapi_components_with_config(graph, ty, JsonSchemaConfig::EXTERNAL_INPUT, Some("Input_")) +} + +/// Render a schema bundle for externally visible output. +/// +/// Host-managed capability leaves describe only their redacted placeholders. +pub fn to_external_output_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value { + to_openapi_components_with_config( + graph, + ty, + JsonSchemaConfig::EXTERNAL_OUTPUT, + Some("Output_"), + ) +} + +fn to_openapi_components_with_config( + graph: &SchemaGraph, + ty: &SchemaType, + config: JsonSchemaConfig, + component_prefix: Option<&str>, +) -> Value { // OpenAPI renders the canonical structural form; only ref-rewriting and // `$schema` omission differ, both handled below. - let config = OPENAPI_CONFIG; let table = build_branch_name_table(graph, ty); - let root = rewrite_refs(render_type(graph, ty, true, &table, config)); + let root = rewrite_refs( + render_type(graph, ty, true, &table, config), + component_prefix, + ); let mut defs = render_defs(graph, &table, config); add_union_branch_defs(graph, ty, &mut defs, &table, config); // `$defs` map keys are raw per RFC 6901 §4: the JSON Pointer token in a @@ -72,7 +101,12 @@ pub fn to_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value { // typical TypeIds never trigger this. let schemas: Map = defs .into_iter() - .map(|(k, v)| (k, rewrite_refs(v))) + .map(|(key, value)| { + ( + format!("{}{key}", component_prefix.unwrap_or_default()), + rewrite_refs(value, component_prefix), + ) + }) .collect(); let mut out = Map::new(); @@ -88,12 +122,12 @@ pub fn to_openapi_components(graph: &SchemaGraph, ty: &SchemaType) -> Value { Value::Object(out) } -fn rewrite_refs(mut v: Value) -> Value { - rewrite_refs_in_place(&mut v); +fn rewrite_refs(mut v: Value, component_prefix: Option<&str>) -> Value { + rewrite_refs_in_place(&mut v, component_prefix); v } -fn rewrite_refs_in_place(v: &mut Value) { +fn rewrite_refs_in_place(v: &mut Value, component_prefix: Option<&str>) { match v { Value::Object(map) => { // Rewrite both `$ref` pointers and discriminator-mapping @@ -101,7 +135,10 @@ fn rewrite_refs_in_place(v: &mut Value) { if let Some(Value::String(ptr)) = map.get_mut("$ref") && let Some(rest) = ptr.strip_prefix("#/$defs/") { - *ptr = format!("#/components/schemas/{rest}"); + *ptr = format!( + "#/components/schemas/{}{rest}", + component_prefix.unwrap_or_default() + ); } if let Some(Value::Object(disc)) = map.get_mut("discriminator") && let Some(Value::Object(mapping)) = disc.get_mut("mapping") @@ -110,17 +147,20 @@ fn rewrite_refs_in_place(v: &mut Value) { if let Value::String(s) = value && let Some(rest) = s.strip_prefix("#/$defs/") { - *s = format!("#/components/schemas/{rest}"); + *s = format!( + "#/components/schemas/{}{rest}", + component_prefix.unwrap_or_default() + ); } } } for value in map.values_mut() { - rewrite_refs_in_place(value); + rewrite_refs_in_place(value, component_prefix); } } Value::Array(arr) => { for item in arr { - rewrite_refs_in_place(item); + rewrite_refs_in_place(item, component_prefix); } } _ => {} diff --git a/golem-common/src/schema/render/tests/json_schema_tests.rs b/golem-common/src/schema/render/tests/json_schema_tests.rs index 4ff1ee399d..32016b22f1 100644 --- a/golem-common/src/schema/render/tests/json_schema_tests.rs +++ b/golem-common/src/schema/render/tests/json_schema_tests.rs @@ -957,6 +957,87 @@ mod agent_entry_points { assert!(rendered.get("$schema").is_none()); } + #[test] + fn external_input_schema_rejects_nested_host_managed_capabilities() { + let capabilities = SchemaType::record(vec![ + NamedFieldType { + name: "secret".to_string(), + body: SchemaType::secret(Default::default()), + metadata: Default::default(), + }, + NamedFieldType { + name: "quota".to_string(), + body: SchemaType::quota_token(Default::default()), + metadata: Default::default(), + }, + NamedFieldType { + name: "card".to_string(), + body: SchemaType::permission_card(Default::default()), + metadata: Default::default(), + }, + ]); + let input = InputSchema::Parameters(vec![NamedField::user_supplied( + "capabilities", + capabilities, + )]); + let doc = input_schema_to_json_schema( + &SchemaGraph::empty(), + &input, + JsonSchemaConfig::WITHOUT_DRAFT_MARKER, + ); + let properties = &doc["properties"]["capabilities"]["properties"]; + + for name in ["secret", "quota", "card"] { + assert_eq!( + properties[name]["not"], + json!({}), + "external input capability must be unsatisfiable: {doc}" + ); + } + } + + #[test] + fn external_output_schema_exposes_only_nested_redacted_placeholders() { + let capabilities = SchemaType::record(vec![ + NamedFieldType { + name: "secret".to_string(), + body: SchemaType::secret(Default::default()), + metadata: Default::default(), + }, + NamedFieldType { + name: "quota".to_string(), + body: SchemaType::quota_token(Default::default()), + metadata: Default::default(), + }, + NamedFieldType { + name: "card".to_string(), + body: SchemaType::permission_card(Default::default()), + metadata: Default::default(), + }, + ]); + let doc = output_schema_to_json_schema( + &SchemaGraph::empty(), + &OutputSchema::Single(Box::new(capabilities)), + JsonSchemaConfig::WITHOUT_DRAFT_MARKER, + ) + .expect("output schema"); + let properties = &doc["properties"]; + + assert_eq!(properties["secret"]["const"], json!("")); + assert_eq!( + properties["quota"]["const"], + json!("") + ); + assert_eq!( + properties["card"]["const"], + json!("") + ); + for name in ["secret", "quota", "card"] { + assert_eq!(properties[name]["type"], json!("string")); + assert!(properties[name].get("properties").is_none()); + } + } + #[test] fn text_with_languages_renders_canonical_shape() { use crate::schema::schema_type::TextRestrictions; diff --git a/golem-common/src/schema/render/tests/json_value_tests.rs b/golem-common/src/schema/render/tests/json_value_tests.rs index 1b7e69f782..d6cbd99012 100644 --- a/golem-common/src/schema/render/tests/json_value_tests.rs +++ b/golem-common/src/schema/render/tests/json_value_tests.rs @@ -16,7 +16,9 @@ use crate::schema::graph::SchemaGraph; use crate::schema::metadata::Role; use crate::schema::proptest_strategies::schema_values_eq; use crate::schema::render::error::RenderError; -use crate::schema::render::json_value::{from_json_value, to_json_value, to_json_value_redacted}; +use crate::schema::render::json_value::{ + from_json_value, from_untrusted_json_value, to_json_value, to_json_value_redacted, +}; use crate::schema::render::tests::paired_strategy::paired_strategy; use crate::schema::schema_type::{ DiscriminatorRule, FieldDiscriminator, NamedFieldType, PermissionCardSpec, QuotaTokenSpec, @@ -798,6 +800,150 @@ fn permission_card_json_round_trip_matches_schema_and_redacts() { assert_eq!(redacted, json!("")); } +#[test] +fn untrusted_json_rejects_every_host_managed_capability_without_changing_trusted_decode() { + let cases = vec![ + ( + SchemaType::secret(SecretSpec::default()), + secret_value(), + "secret", + ), + ( + SchemaType::quota_token(QuotaTokenSpec::default()), + quota_token_value(), + "quota-token", + ), + ( + SchemaType::permission_card(PermissionCardSpec { polymorphic: false }), + SchemaValue::PermissionCard(PermissionCardValuePayload { + card_id: uuid::Uuid::from_u128(1), + parent_ids: Vec::new(), + expires_at: None, + polymorphic: false, + }), + "permission-card", + ), + ]; + + for (ty, value, kind) in cases { + let graph = SchemaGraph::anonymous(ty.clone()); + let json = to_json_value(&graph, &ty, &value).expect("trusted encode"); + assert_eq!( + from_json_value(&graph, &ty, &json).expect("trusted decode"), + value + ); + + let error = from_untrusted_json_value(&graph, &ty, &json) + .expect_err("external JSON must not construct a capability"); + assert_eq!( + error, + RenderError::ValueMismatch { + path: "$".to_string(), + reason: format!( + "host-managed capability `{kind}` cannot be constructed from external JSON" + ), + } + ); + } +} + +#[test] +fn untrusted_json_rejects_nested_capability_at_the_selected_value_path() { + let ty = SchemaType::record(vec![NamedFieldType { + name: "items".to_string(), + body: SchemaType::list(SchemaType::secret(SecretSpec::default())), + metadata: Default::default(), + }]); + let graph = SchemaGraph::anonymous(ty.clone()); + let secret_json = to_json_value( + &SchemaGraph::anonymous(SchemaType::secret(SecretSpec::default())), + &SchemaType::secret(SecretSpec::default()), + &secret_value(), + ) + .expect("trusted secret encode"); + + let error = from_untrusted_json_value(&graph, &ty, &json!({ "items": [secret_json] })) + .expect_err("nested secret must be rejected"); + assert!(matches!( + error, + RenderError::ValueMismatch { path, reason } + if path == ".items[0]" && reason.contains("`secret`") + )); +} + +#[test] +fn untrusted_json_accepts_absent_and_unselected_capability_branches() { + let optional = SchemaType::option(SchemaType::secret(SecretSpec::default())); + let optional_graph = SchemaGraph::anonymous(optional.clone()); + assert_eq!( + from_untrusted_json_value(&optional_graph, &optional, &json!(null)).unwrap(), + SchemaValue::Option { inner: None } + ); + + let variant = SchemaType::variant(vec![ + VariantCaseType { + name: "safe".to_string(), + payload: None, + metadata: Default::default(), + }, + VariantCaseType { + name: "capability".to_string(), + payload: Some(SchemaType::quota_token(QuotaTokenSpec::default())), + metadata: Default::default(), + }, + ]); + let variant_graph = SchemaGraph::anonymous(variant.clone()); + assert_eq!( + from_untrusted_json_value(&variant_graph, &variant, &json!("safe")).unwrap(), + SchemaValue::Variant(VariantValuePayload { + case: 0, + payload: None, + }) + ); + + let result = SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::permission_card( + PermissionCardSpec::default(), + ))), + }); + let result_graph = SchemaGraph::anonymous(result.clone()); + assert_eq!( + from_untrusted_json_value(&result_graph, &result, &json!({ "ok": "safe" })).unwrap(), + SchemaValue::Result(crate::schema::ResultValuePayload::Ok { + value: Some(Box::new(SchemaValue::String("safe".to_string()))), + }) + ); + + let union = SchemaType::union(UnionSpec { + branches: vec![ + UnionBranch { + tag: "safe".to_string(), + body: SchemaType::string(), + discriminator: DiscriminatorRule::Prefix { + prefix: "safe:".to_string(), + }, + metadata: Default::default(), + }, + UnionBranch { + tag: "capability".to_string(), + body: SchemaType::secret(SecretSpec::default()), + discriminator: DiscriminatorRule::FieldEquals(FieldDiscriminator { + field_name: "secretId".to_string(), + literal: None, + }), + metadata: Default::default(), + }, + ], + }); + let union_graph = SchemaGraph::anonymous(union.clone()); + assert!(matches!( + from_untrusted_json_value(&union_graph, &union, &json!("safe:value")).unwrap(), + SchemaValue::Union(UnionValuePayload { tag, body }) + if tag == "safe" && *body == SchemaValue::String("safe:value".to_string()) + )); +} + /// Redaction recurses through every container kind the walker descends into, /// mirroring `redacted_schema_value_debug`. One representative value per /// container path; the assertion checks the capability never leaks and the diff --git a/golem-registry-service/src/api/agent_secrets.rs b/golem-registry-service/src/api/agent_secrets.rs index 95b149acfe..359f59b6e6 100644 --- a/golem-registry-service/src/api/agent_secrets.rs +++ b/golem-registry-service/src/api/agent_secrets.rs @@ -17,9 +17,12 @@ use crate::services::agent_secret::AgentSecretService; use crate::services::auth::AuthService; use golem_common::model::Page; use golem_common::model::agent_secret::{ - AgentSecretCreation, AgentSecretDto, AgentSecretId, AgentSecretRevision, AgentSecretUpdate, + AgentSecretDto as DomainAgentSecretDto, AgentSecretId, AgentSecretRevision, }; use golem_common::model::environment::EnvironmentId; +use golem_common::model::external_agent_secret::{ + AgentSecretCreation, AgentSecretDto, AgentSecretUpdate, +}; use golem_common::recorded_http_api_request; use golem_service_base::api_tags::ApiTags; use golem_service_base::model::auth::AuthCtx; @@ -87,10 +90,12 @@ impl AgentSecretsApi { ) -> ApiResult> { let result = self .agent_secret_service - .create(environment_id, payload, &auth) + .create(environment_id, payload.into(), &auth) .await?; - Ok(Json(result.into())) + let result = AgentSecretDto::try_from(DomainAgentSecretDto::from(result)) + .map_err(anyhow::Error::msg)?; + Ok(Json(result)) } /// List all agent secrets of the environment @@ -130,7 +135,11 @@ impl AgentSecretsApi { .list_in_environment(environment_id, &auth) .await?; - let converted = result.into_iter().map(AgentSecretDto::from).collect(); + let converted = result + .into_iter() + .map(|value| AgentSecretDto::try_from(DomainAgentSecretDto::from(value))) + .collect::, _>>() + .map_err(anyhow::Error::msg)?; Ok(Json(Page { values: converted })) } @@ -170,7 +179,9 @@ impl AgentSecretsApi { .agent_secret_service .get(agent_secret_id, &auth) .await?; - Ok(Json(result.into())) + let result = AgentSecretDto::try_from(DomainAgentSecretDto::from(result)) + .map_err(anyhow::Error::msg)?; + Ok(Json(result)) } /// Update agent secret @@ -208,9 +219,11 @@ impl AgentSecretsApi { ) -> ApiResult> { let result = self .agent_secret_service - .update(agent_secret_id, data, &auth) + .update(agent_secret_id, data.into(), &auth) .await?; - Ok(Json(result.into())) + let result = AgentSecretDto::try_from(DomainAgentSecretDto::from(result)) + .map_err(anyhow::Error::msg)?; + Ok(Json(result)) } /// Delete agent secret @@ -250,6 +263,38 @@ impl AgentSecretsApi { .agent_secret_service .delete(agent_secret_id, current_revision, &auth) .await?; - Ok(Json(result.into())) + let result = AgentSecretDto::try_from(DomainAgentSecretDto::from(result)) + .map_err(anyhow::Error::msg)?; + Ok(Json(result)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use golem_common::model::agent_secret::{ + AgentSecretCreation as DomainAgentSecretCreation, AgentSecretPath, + }; + use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue, SecretValuePayload}; + use test_r::test; + + #[test] + fn agent_secret_api_rejects_forged_host_managed_values() { + let creation = DomainAgentSecretCreation { + path: AgentSecretPath(vec!["credential".to_string()]), + secret_type: SchemaGraph::anonymous(SchemaType::string()), + secret_value: Some(SchemaValue::Secret(SecretValuePayload { + secret_id: uuid::Uuid::nil(), + config_key: None, + version: 1, + resolved_at: Utc.timestamp_opt(0, 0).unwrap(), + category: None, + })), + }; + + let json = serde_json::to_value(creation).unwrap(); + let error = serde_json::from_value::(json).unwrap_err(); + assert!(error.to_string().contains("secret")); } } diff --git a/golem-registry-service/src/services/component/write.rs b/golem-registry-service/src/services/component/write.rs index acc2e3df72..58333c82df 100644 --- a/golem-registry-service/src/services/component/write.rs +++ b/golem-registry-service/src/services/component/write.rs @@ -60,7 +60,7 @@ use golem_common::model::worker::AgentConfigEntryDto; use golem_common::model::worker::TypedAgentConfigEntry; use golem_common::schema::SchemaValue; use golem_common::schema::agent::{AgentTypeSchema, typed_schema_value_with_projected_defs}; -use golem_common::schema::render::from_json_value; +use golem_common::schema::render::from_untrusted_json_value; use golem_common::schema::tool::Tool; use golem_common::schema::tool::validation::validate_tool; use golem_common::schema::validation::{is_equivalent_cross_graph, validate_value}; @@ -1461,13 +1461,12 @@ fn validate_and_transform_config_entries( let declared_type = &matching_declaration.value_type; let schema_value: SchemaValue = - from_json_value(&agent_type.schema, declared_type, &config_value.value.0).map_err( - |err| ComponentError::AgentConfigTypeMismatch { + from_untrusted_json_value(&agent_type.schema, declared_type, &config_value.value.0) + .map_err(|err| ComponentError::AgentConfigTypeMismatch { agent: agent_type.type_name.clone(), key: config_value.path.clone(), errors: vec![format!("config value is not a valid schema value: {err}")], - }, - )?; + })?; validate_value(&agent_type.schema, declared_type, &schema_value).map_err(|errors| { ComponentError::AgentConfigTypeMismatch { diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index 6e77c2f0bb..186e2771ea 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -1067,7 +1067,7 @@ fn compile_tool_binding( /// /// The deployment request DTO carries ergonomic, human-shaped JSON (raw /// scalars, field-named record objects). It is decoded directly into a -/// schema-native [`SchemaValue`] via [`render::from_json_value`], which both +/// schema-native [`SchemaValue`] via [`render::from_untrusted_json_value`], which both /// type-checks the payload against the agent-declared schema and produces the /// value in one step. fn parse_default_secret_value( @@ -1077,7 +1077,7 @@ fn parse_default_secret_value( ) -> Result, DeployValidationError> { default .map(|sd| { - render::from_json_value(schema, &schema.root, &sd.secret_value).map_err(|e| { + render::from_untrusted_json_value(schema, &schema.root, &sd.secret_value).map_err(|e| { DeployValidationError::AgentSecretDefaultTypeMismatch { path: path.clone(), errors: vec![e.to_string()], diff --git a/golem-schema/src/schema/host_managed.rs b/golem-schema/src/schema/host_managed.rs index 119dec957c..75027586cf 100644 --- a/golem-schema/src/schema/host_managed.rs +++ b/golem-schema/src/schema/host_managed.rs @@ -25,11 +25,14 @@ //! from one place. Adding a future capability case means adding one variant //! here; the consumers pick it up automatically. +use crate::schema::graph::SchemaGraph; +use crate::schema::metadata::TypeId; use crate::schema::schema_type::SchemaType; use crate::schema::schema_value::{ ResultValuePayload, SchemaValue, UnionValuePayload, VariantValuePayload, }; -use std::fmt; +use std::collections::HashSet; +use std::fmt::{self, Write}; /// A closed set of "host-managed" capability kinds. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] @@ -81,6 +84,258 @@ impl HostManagedKind { } } +/// The first host-managed capability reached while traversing a schema type or +/// value tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostManagedOccurrence { + pub kind: HostManagedKind, + pub path: String, +} + +impl fmt::Display for HostManagedOccurrence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at {}", self.kind.kind_name(), self.path) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostManagedTraversalError { + DanglingRef { id: TypeId, path: String }, + DuplicateTypeId { id: TypeId, path: String }, +} + +impl fmt::Display for HostManagedTraversalError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DanglingRef { id, path } => { + write!(f, "dangling schema reference {id:?} at {path}") + } + Self::DuplicateTypeId { id, path } => { + write!(f, "duplicate schema type id {id:?} reached at {path}") + } + } + } +} + +impl std::error::Error for HostManagedTraversalError {} + +/// Finds the first host-managed capability in `value`, including nested +/// occurrences in every structural container. +pub fn find_host_managed_value(value: &SchemaValue) -> Option { + find_host_managed_value_at(value, "$".to_string()) +} + +fn find_host_managed_value_at(value: &SchemaValue, path: String) -> Option { + if let Some(kind) = HostManagedKind::from_value(value) { + return Some(HostManagedOccurrence { kind, path }); + } + + match value { + SchemaValue::Record { fields } => fields.iter().enumerate().find_map(|(index, value)| { + find_host_managed_value_at(value, format!("{path}.fields[{index}]")) + }), + SchemaValue::Variant(value) => value.payload.as_deref().and_then(|payload| { + find_host_managed_value_at(payload, format!("{path}.variant[{}]", value.case)) + }), + SchemaValue::Tuple { elements } => find_host_managed_sequence(elements, &path, "tuple"), + SchemaValue::List { elements } => find_host_managed_sequence(elements, &path, "list"), + SchemaValue::FixedList { elements } => { + find_host_managed_sequence(elements, &path, "fixed-list") + } + SchemaValue::Map { entries } => { + entries + .iter() + .enumerate() + .find_map(|(index, (key, value))| { + find_host_managed_value_at(key, format!("{path}.map[{index}].key")).or_else( + || find_host_managed_value_at(value, format!("{path}.map[{index}].value")), + ) + }) + } + SchemaValue::Option { inner } => inner + .as_deref() + .and_then(|value| find_host_managed_value_at(value, format!("{path}.some"))), + SchemaValue::Result(ResultValuePayload::Ok { value }) => value + .as_deref() + .and_then(|value| find_host_managed_value_at(value, format!("{path}.ok"))), + SchemaValue::Result(ResultValuePayload::Err { value }) => value + .as_deref() + .and_then(|value| find_host_managed_value_at(value, format!("{path}.err"))), + SchemaValue::Union(value) => { + find_host_managed_value_at(&value.body, append_named_path(&path, "union", &value.tag)) + } + _ => None, + } +} + +fn find_host_managed_sequence( + values: &[SchemaValue], + path: &str, + kind: &str, +) -> Option { + values.iter().enumerate().find_map(|(index, value)| { + find_host_managed_value_at(value, format!("{path}.{kind}[{index}]")) + }) +} + +/// Finds the first host-managed capability type reachable from `ty` through +/// `graph`. Only definitions reachable from `ty` are considered. Recursive +/// references are followed once per active path, so recursive schemas +/// terminate without suppressing occurrences reached through sibling paths. +pub fn find_host_managed_type( + graph: &SchemaGraph, + ty: &SchemaType, +) -> Result, HostManagedTraversalError> { + find_host_managed_type_at(graph, ty, "$".to_string(), &mut HashSet::new()) +} + +fn find_host_managed_type_at( + graph: &SchemaGraph, + ty: &SchemaType, + path: String, + visiting: &mut HashSet, +) -> Result, HostManagedTraversalError> { + if let Some(kind) = HostManagedKind::from_type(ty) { + return Ok(Some(HostManagedOccurrence { kind, path })); + } + + match ty { + SchemaType::Ref { id, .. } => { + if !visiting.insert(id.clone()) { + return Ok(None); + } + let mut definitions = graph.defs.iter().filter(|definition| definition.id == *id); + let definition = + definitions + .next() + .ok_or_else(|| HostManagedTraversalError::DanglingRef { + id: id.clone(), + path: path.clone(), + })?; + if definitions.next().is_some() { + visiting.remove(id); + return Err(HostManagedTraversalError::DuplicateTypeId { + id: id.clone(), + path, + }); + } + let result = find_host_managed_type_at(graph, &definition.body, path, visiting); + visiting.remove(id); + result + } + SchemaType::Record { fields, .. } => { + for field in fields { + if let Some(occurrence) = find_host_managed_type_at( + graph, + &field.body, + append_named_path(&path, "field", &field.name), + visiting, + )? { + return Ok(Some(occurrence)); + } + } + Ok(None) + } + SchemaType::Variant { cases, .. } => { + for case in cases { + if let Some(payload) = &case.payload + && let Some(occurrence) = find_host_managed_type_at( + graph, + payload, + append_named_path(&path, "variant", &case.name), + visiting, + )? + { + return Ok(Some(occurrence)); + } + } + Ok(None) + } + SchemaType::Tuple { elements, .. } => { + find_host_managed_type_sequence(graph, elements, &path, "tuple", visiting) + } + SchemaType::List { element, .. } => { + find_host_managed_type_at(graph, element, format!("{path}.list[]"), visiting) + } + SchemaType::FixedList { element, .. } => { + find_host_managed_type_at(graph, element, format!("{path}.fixed-list[]"), visiting) + } + SchemaType::Map { key, value, .. } => { + if let Some(occurrence) = + find_host_managed_type_at(graph, key, format!("{path}.map.key"), visiting)? + { + return Ok(Some(occurrence)); + } + find_host_managed_type_at(graph, value, format!("{path}.map.value"), visiting) + } + SchemaType::Option { inner, .. } => { + find_host_managed_type_at(graph, inner, format!("{path}.some"), visiting) + } + SchemaType::Result { spec, .. } => { + if let Some(ok) = spec.ok.as_deref() + && let Some(occurrence) = + find_host_managed_type_at(graph, ok, format!("{path}.ok"), visiting)? + { + return Ok(Some(occurrence)); + } + match spec.err.as_deref() { + Some(err) => find_host_managed_type_at(graph, err, format!("{path}.err"), visiting), + None => Ok(None), + } + } + SchemaType::Union { spec, .. } => { + for branch in &spec.branches { + if let Some(occurrence) = find_host_managed_type_at( + graph, + &branch.body, + append_named_path(&path, "union", &branch.tag), + visiting, + )? { + return Ok(Some(occurrence)); + } + } + Ok(None) + } + SchemaType::Future { inner, .. } => match inner.as_deref() { + Some(inner) => { + find_host_managed_type_at(graph, inner, format!("{path}.future"), visiting) + } + None => Ok(None), + }, + SchemaType::Stream { inner, .. } => match inner.as_deref() { + Some(inner) => { + find_host_managed_type_at(graph, inner, format!("{path}.stream"), visiting) + } + None => Ok(None), + }, + _ => Ok(None), + } +} + +fn find_host_managed_type_sequence( + graph: &SchemaGraph, + elements: &[SchemaType], + path: &str, + kind: &str, + visiting: &mut HashSet, +) -> Result, HostManagedTraversalError> { + for (index, element) in elements.iter().enumerate() { + if let Some(occurrence) = + find_host_managed_type_at(graph, element, format!("{path}.{kind}[{index}]"), visiting)? + { + return Ok(Some(occurrence)); + } + } + Ok(None) +} + +fn append_named_path(path: &str, kind: &str, name: &str) -> String { + let mut result = format!("{path}.{kind}["); + write!(&mut result, "{name:?}").expect("writing to String cannot fail"); + result.push(']'); + result +} + /// Replace every host-managed capability value (see [`HostManagedKind`]) with a /// plain string placeholder, recursing through every container. /// @@ -402,8 +657,14 @@ fn fmt_opt(value: Option<&SchemaValue>, f: &mut fmt::Formatter<'_>) -> fmt::Resu mod tests { use super::*; use crate::model::EnvironmentId; - use crate::schema::schema_type::{QuotaTokenSpec, SecretSpec}; - use crate::schema::schema_value::{QuotaTokenValuePayload, SecretValuePayload}; + use crate::schema::graph::SchemaTypeDef; + use crate::schema::metadata::MetadataEnvelope; + use crate::schema::schema_type::{ + NamedFieldType, PermissionCardSpec, QuotaTokenSpec, SecretSpec, + }; + use crate::schema::schema_value::{ + PermissionCardValuePayload, QuotaTokenValuePayload, SecretValuePayload, + }; use chrono::{TimeZone, Utc}; use test_r::test; @@ -427,6 +688,15 @@ mod tests { }) } + fn permission_card_value() -> SchemaValue { + SchemaValue::PermissionCard(PermissionCardValuePayload { + card_id: uuid::Uuid::nil(), + parent_ids: Vec::new(), + expires_at: None, + polymorphic: false, + }) + } + #[test] fn classifies_capability_types_and_values() { assert_eq!( @@ -464,6 +734,131 @@ mod tests { ); } + #[test] + fn finds_nested_host_managed_value_with_path() { + let value = SchemaValue::Record { + fields: vec![SchemaValue::Map { + entries: vec![( + SchemaValue::String("key".to_string()), + permission_card_value(), + )], + }], + }; + + assert_eq!( + find_host_managed_value(&value), + Some(HostManagedOccurrence { + kind: HostManagedKind::PermissionCard, + path: "$.fields[0].map[0].value".to_string(), + }) + ); + } + + #[test] + fn finds_reachable_type_after_recursive_back_edge() { + let tree = TypeId::new("tree"); + let graph = SchemaGraph { + defs: vec![SchemaTypeDef { + id: tree.clone(), + name: Some("Tree".to_string()), + body: SchemaType::Record { + fields: vec![ + NamedFieldType { + name: "next".to_string(), + body: SchemaType::Option { + inner: Box::new(SchemaType::ref_to(tree.clone())), + metadata: MetadataEnvelope::default(), + }, + metadata: MetadataEnvelope::default(), + }, + NamedFieldType { + name: "token".to_string(), + body: SchemaType::quota_token(QuotaTokenSpec::default()), + metadata: MetadataEnvelope::default(), + }, + ], + metadata: MetadataEnvelope::default(), + }, + }], + root: SchemaType::ref_to(tree), + }; + + assert_eq!( + find_host_managed_type(&graph, &graph.root), + Ok(Some(HostManagedOccurrence { + kind: HostManagedKind::QuotaToken, + path: "$.field[\"token\"]".to_string(), + })) + ); + } + + #[test] + fn ignores_unreachable_host_managed_definition() { + let graph = SchemaGraph { + defs: vec![SchemaTypeDef { + id: TypeId::new("unused"), + name: None, + body: SchemaType::permission_card(PermissionCardSpec::default()), + }], + root: SchemaType::string(), + }; + + assert_eq!(find_host_managed_type(&graph, &graph.root), Ok(None)); + } + + #[test] + fn reachable_dangling_and_duplicate_refs_fail_closed() { + let missing = TypeId::new("missing\nref"); + let dangling = SchemaGraph::anonymous(SchemaType::ref_to(missing.clone())); + assert_eq!( + find_host_managed_type(&dangling, &dangling.root), + Err(HostManagedTraversalError::DanglingRef { + id: missing, + path: "$".to_string(), + }) + ); + + let duplicate = TypeId::new("duplicate"); + let duplicated = SchemaGraph { + defs: vec![ + SchemaTypeDef { + id: duplicate.clone(), + name: None, + body: SchemaType::string(), + }, + SchemaTypeDef { + id: duplicate.clone(), + name: None, + body: SchemaType::permission_card(PermissionCardSpec::default()), + }, + ], + root: SchemaType::ref_to(duplicate.clone()), + }; + assert_eq!( + find_host_managed_type(&duplicated, &duplicated.root), + Err(HostManagedTraversalError::DuplicateTypeId { + id: duplicate, + path: "$".to_string(), + }) + ); + } + + #[test] + fn escapes_union_tags_in_value_paths() { + let value = SchemaValue::Union(UnionValuePayload { + tag: "branch\"]\nforged".to_string(), + body: Box::new(secret_value()), + }); + + assert_eq!( + find_host_managed_value(&value), + Some(HostManagedOccurrence { + kind: HostManagedKind::Secret, + path: "$.union[\"branch\\\"]\\nforged\"]".to_string(), + }) + ); + } + #[test] fn redacted_debug_hides_secret_material() { let rendered = format!("{:?}", redacted_schema_value_debug(&secret_value())); diff --git a/golem-schema/src/schema/mod.rs b/golem-schema/src/schema/mod.rs index c382c89bc1..cf648ad1ee 100644 --- a/golem-schema/src/schema/mod.rs +++ b/golem-schema/src/schema/mod.rs @@ -51,7 +51,10 @@ pub use fingerprint::{ #[cfg(feature = "derive")] pub use golem_schema_derive::{FromSchema, IntoSchema, Schema}; pub use graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue}; -pub use host_managed::{HostManagedKind, RedactedSchemaValue, redacted_schema_value_debug}; +pub use host_managed::{ + HostManagedKind, HostManagedOccurrence, HostManagedTraversalError, RedactedSchemaValue, + find_host_managed_type, find_host_managed_value, redacted_schema_value_debug, +}; pub use metadata::{MetadataEnvelope, Role, TypeId}; pub use schema_type::{ BinaryRestrictions, DiscriminatorRule, FieldDiscriminator, NamedFieldType, PathDirection, diff --git a/golem-service-base/src/model/component.rs b/golem-service-base/src/model/component.rs index dfcc78f079..959dd21b0b 100644 --- a/golem-service-base/src/model/component.rs +++ b/golem-service-base/src/model/component.rs @@ -43,6 +43,8 @@ pub struct Component { impl From for golem_common::model::component::ComponentDto { fn from(value: Component) -> Self { + let mut metadata = value.metadata; + metadata.redact_host_managed_values_for_external(); Self { id: value.id, revision: value.revision, @@ -51,7 +53,7 @@ impl From for golem_common::model::component::ComponentDto { account_id: value.account_id, component_name: value.component_name, component_size: value.component_size, - metadata: value.metadata, + metadata, created_at: value.created_at, wasm_hash: value.wasm_hash, hash: value.hash, diff --git a/golem-test-framework/src/config/dsl_impl.rs b/golem-test-framework/src/config/dsl_impl.rs index c9c9c1d663..0ead8e6505 100644 --- a/golem-test-framework/src/config/dsl_impl.rs +++ b/golem-test-framework/src/config/dsl_impl.rs @@ -60,7 +60,7 @@ use golem_common::model::worker::{ use golem_common::model::{ AgentEvent, AgentFilter, AgentId, IdempotencyKey, OplogIndex, PromiseId, ScanCursor, }; -use golem_common::schema::TypedSchemaValue; +use golem_common::schema::{ExternalSchemaValue, TypedSchemaValue}; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; @@ -512,11 +512,13 @@ impl TestDsl for TestUserContext { app_name: app_name.0, env_name: env_name.0, agent_type_name: agent_id.agent_type.0.clone(), - parameters: agent_id.parameters.value().clone(), + parameters: ExternalSchemaValue::try_from(agent_id.parameters.value().clone()) + .map_err(anyhow::Error::msg)?, phantom_id: agent_id.phantom_id, config: None, method_name: method_name.to_string(), - method_parameters, + method_parameters: ExternalSchemaValue::try_from(method_parameters) + .map_err(anyhow::Error::msg)?, mode: golem_client::model::AgentInvocationMode::Schedule, schedule_at: None, idempotency_key: None, @@ -577,11 +579,13 @@ impl TestDsl for TestUserContext { app_name: app_name.0, env_name: env_name.0, agent_type_name: agent_id.agent_type.0.clone(), - parameters: agent_id.parameters.value().clone(), + parameters: ExternalSchemaValue::try_from(agent_id.parameters.value().clone()) + .map_err(anyhow::Error::msg)?, phantom_id: agent_id.phantom_id, config: None, method_name: method_name.to_string(), - method_parameters, + method_parameters: ExternalSchemaValue::try_from(method_parameters) + .map_err(anyhow::Error::msg)?, mode: golem_client::model::AgentInvocationMode::Await, schedule_at: None, idempotency_key: None, @@ -593,7 +597,7 @@ impl TestDsl for TestUserContext { match result.result { Some(typed_output) => { - let (_graph, value) = typed_output.into_parts(); + let (_graph, value) = typed_output.into_inner().into_parts(); Ok(AgentResult::new(Some(value))) } None => Ok(AgentResult::new(None)), diff --git a/golem-worker-executor/src/model/public_oplog/wit.rs b/golem-worker-executor/src/model/public_oplog/wit.rs index 4ef445f1fb..3da81d69e6 100644 --- a/golem-worker-executor/src/model/public_oplog/wit.rs +++ b/golem-worker-executor/src/model/public_oplog/wit.rs @@ -225,7 +225,8 @@ pub(crate) fn reject_quota_handles_in_oplog_entries< impl TryFrom for oplog::PublicOplogEntry { type Error = String; - fn try_from(value: PublicOplogEntry) -> Result { + fn try_from(mut value: PublicOplogEntry) -> Result { + value.redact_host_managed_values_for_external(); Ok(match value { PublicOplogEntry::Create(CreateParams { timestamp, diff --git a/golem-worker-executor/src/worker/agent_config.rs b/golem-worker-executor/src/worker/agent_config.rs index 72b38baa58..2899379d10 100644 --- a/golem-worker-executor/src/worker/agent_config.rs +++ b/golem-worker-executor/src/worker/agent_config.rs @@ -16,7 +16,7 @@ use golem_common::model::agent::{AgentConfigSource, ParsedAgentId}; use golem_common::model::agent_secret::CanonicalAgentSecretPath; use golem_common::model::worker::{AgentConfigEntryDto, TypedAgentConfigEntry}; use golem_common::schema::agent::typed_schema_value_with_projected_defs; -use golem_common::schema::render::from_json_value; +use golem_common::schema::render::from_untrusted_json_value; use golem_common::schema::schema_type::SecretSpec; use golem_common::schema::validation::{is_equivalent_cross_graph, validate_value}; use golem_common::schema::{ @@ -179,12 +179,14 @@ pub fn parse_worker_creation_agent_config( let declared_type = &config_declaration.value_type; let schema_value: SchemaValue = - from_json_value(&agent_type.schema, declared_type, &entry.value.0).map_err(|err| { - WorkerExecutorError::invalid_request(format!( - "config value for path {} is not a valid schema value: {err}", - entry.path.join(".") - )) - })?; + from_untrusted_json_value(&agent_type.schema, declared_type, &entry.value.0).map_err( + |err| { + WorkerExecutorError::invalid_request(format!( + "config value for path {} is not a valid schema value: {err}", + entry.path.join(".") + )) + }, + )?; validate_value(&agent_type.schema, declared_type, &schema_value).map_err(|errors| { WorkerExecutorError::invalid_request(format!( diff --git a/golem-worker-executor/tests/scope_cards.rs b/golem-worker-executor/tests/scope_cards.rs index 8c1710817d..73279c5071 100644 --- a/golem-worker-executor/tests/scope_cards.rs +++ b/golem-worker-executor/tests/scope_cards.rs @@ -218,6 +218,7 @@ impl CardService for ScopeCardService { card: StoredCard, _provenance: CardManagedByRuntimeDerived, ) -> Result { + self.authority.add_card(card.clone()); Ok(card) } @@ -3930,6 +3931,104 @@ async fn secret_reveal_authorizes_before_secret_revision_lookup( Ok(()) } +#[test] +#[timeout("3m")] +#[tracing::instrument] +async fn capabilities_round_trip_between_agents_and_codec_rejects_reuse( + last_unique_id: &LastUniqueId, + deps: &WorkerExecutorTestDependencies, + #[tagged_as("agent_sdk_rust")] agent_sdk_rust: &PrecompiledComponent, + _tracing: &Tracing, +) -> anyhow::Result<()> { + let context = TestContext::new(last_unique_id); + let authority = Arc::new(ScopeCardAuthority::default()); + let environment_state_service = Arc::new(TestEnvironmentStateService::default()); + let secret_value = "capability-rpc-secret"; + environment_state_service.set_agent_secret(AgentSecret { + id: AgentSecretId::new(), + environment_id: context.default_environment_id, + path: CanonicalAgentSecretPath(vec!["secretPath".to_string()]), + revision: AgentSecretRevision::INITIAL, + secret_type: SchemaGraph::anonymous(SchemaType::string()), + secret_value: Some(SchemaValue::String(secret_value.to_string())), + }); + let executor = start_with_overrides( + deps, + &context, + TestExecutorOverrides { + create_card_service: Some(Arc::new({ + let authority = authority.clone(); + move || { + Arc::new(ScopeCardService { + authority: authority.clone(), + }) + } + })), + environment_state_service: Some(environment_state_service), + ..Default::default() + }, + ) + .await?; + let component = executor + .component_dep(&context.default_environment_id, agent_sdk_rust) + .unique() + .update_agent_provision_config("CapabilityRpcSender", |config| { + config + .initial_permissions + .lower_bound + .positive + .extend(scope_card_initial_permissions()); + }) + .store() + .await?; + + let sender = agent_id!("CapabilityRpcSender", "capability-round-trip"); + configure_scope_card_root(&authority, &component, &sender)?; + executor.start_agent(&component.id, sender.clone()).await?; + let observations = executor + .invoke_and_await_agent( + &component, + &sender, + "round_trip", + data_value!("capability-round-trip-receiver"), + ) + .await? + .into_typed::, String>>()? + .map_err(anyhow::Error::msg)?; + assert_eq!(observations.len(), 7); + assert_eq!(observations[0], observations[1]); + assert_eq!(observations[0], observations[2]); + assert_eq!(observations[3], observations[4]); + assert_eq!(observations[3], observations[5]); + assert_eq!(observations[6], secret_value); + + let codec_sender = agent_id!("CapabilityRpcSender", "capability-codec-rejections"); + configure_scope_card_root(&authority, &component, &codec_sender)?; + executor + .start_agent(&component.id, codec_sender.clone()) + .await?; + let errors = executor + .invoke_and_await_agent(&component, &codec_sender, "codec_rejections", data_value!()) + .await? + .into_typed::, String>>()? + .map_err(anyhow::Error::msg)?; + assert_eq!(errors.len(), 6); + for (error, expected) in errors.iter().zip([ + "same secret handle appeared more than once", + "secret handle was already transferred", + "same quota-token handle appeared more than once", + "quota-token handle was already transferred", + "same permission-card handle appeared more than once", + "permission-card handle was already transferred", + ]) { + assert!( + error.contains(expected), + "unexpected schema codec error, expected {expected:?}: {error}" + ); + } + Ok(()) +} + #[test] #[timeout("3m")] #[tracing::instrument] diff --git a/golem-worker-service/src/api/agents.rs b/golem-worker-service/src/api/agents.rs index bf677de2be..8538ce1238 100644 --- a/golem-worker-service/src/api/agents.rs +++ b/golem-worker-service/src/api/agents.rs @@ -15,7 +15,7 @@ use golem_common::model::invocation_session_public::{ use golem_common::model::worker::AgentConfigEntryDto; use golem_common::model::{AgentId, IdempotencyKey}; use golem_common::recorded_http_api_request; -use golem_common::schema::{SchemaValue, TypedSchemaValue}; +use golem_common::schema::{ExternalSchemaValue, ExternalTypedSchemaValue}; use golem_service_base::api_tags::ApiTags; use golem_service_base::model::auth::GolemSecurityScheme; use poem::web::websocket::{BoxWebSocketUpgraded, WebSocket, WebSocketConfig}; @@ -227,13 +227,13 @@ pub struct AgentInvocationRequest { pub app_name: ApplicationName, pub env_name: EnvironmentName, pub agent_type_name: AgentTypeName, - pub parameters: SchemaValue, + pub parameters: ExternalSchemaValue, pub phantom_id: Option, #[oai(default)] #[serde(default)] pub config: Vec, pub method_name: String, - pub method_parameters: SchemaValue, + pub method_parameters: ExternalSchemaValue, pub mode: AgentInvocationMode, pub schedule_at: Option>, pub idempotency_key: Option, @@ -247,7 +247,7 @@ pub struct AgentInvocationRequest { pub struct AgentInvocationResult { pub agent_id: AgentId, pub idempotency_key: IdempotencyKey, - pub result: Option, + pub result: Option, pub component_revision: Option, } @@ -258,7 +258,7 @@ pub struct CreateAgentRequest { pub app_name: ApplicationName, pub env_name: EnvironmentName, pub agent_type_name: AgentTypeName, - pub parameters: SchemaValue, + pub parameters: ExternalSchemaValue, pub phantom_id: Option, #[oai(default)] #[serde(default)] @@ -279,9 +279,12 @@ mod tests { AgentInvocationRequest, CreateAgentRequest, INVOCATION_SESSION_MAX_MESSAGE_SIZE, invocation_session_websocket_config, }; + use chrono::{TimeZone, Utc}; + use golem_common::schema::{SchemaValue, SecretValuePayload}; use poem_openapi::types::{ParseFromJSON, ToJSON}; use serde_json::{Value, json}; use test_r::test; + use uuid::Uuid; fn empty_parameter_record() -> Value { json!({ "kind": "record", "value": { "fields": [] } }) @@ -342,6 +345,37 @@ mod tests { assert!(CreateAgentRequest::parse_from_json(Some(request_json)).is_err()); } + #[test] + fn agent_requests_reject_forged_host_managed_parameters() { + let forged = serde_json::to_value(SchemaValue::Secret(SecretValuePayload { + secret_id: Uuid::nil(), + config_key: None, + version: 1, + resolved_at: Utc.timestamp_opt(0, 0).unwrap(), + category: None, + })) + .unwrap(); + + let create = json!({ + "appName": "app", + "envName": "env", + "agentTypeName": "agent", + "parameters": forged, + }); + assert!(CreateAgentRequest::parse_from_json(Some(create)).is_err()); + + let invoke = json!({ + "appName": "app", + "envName": "env", + "agentTypeName": "agent", + "parameters": empty_parameter_record(), + "methodName": "run", + "methodParameters": forged, + "mode": "await", + }); + assert!(AgentInvocationRequest::parse_from_json(Some(invoke)).is_err()); + } + #[test] fn invocation_session_websocket_config_bounds_frames_messages_and_writes() { let config = invocation_session_websocket_config(); diff --git a/golem-worker-service/src/api/worker.rs b/golem-worker-service/src/api/worker.rs index 83d516d409..766d5675a0 100644 --- a/golem-worker-service/src/api/worker.rs +++ b/golem-worker-service/src/api/worker.rs @@ -325,7 +325,8 @@ impl WorkerApi { agent_id: AgentId, auth: AuthCtx, ) -> Result> { - let response = self.worker_service.get_metadata(&agent_id, auth).await?; + let mut response = self.worker_service.get_metadata(&agent_id, auth).await?; + response.redact_host_managed_values_for_external(); Ok(Json(response)) } @@ -422,7 +423,7 @@ impl WorkerApi { None => None, }; - let (cursor, workers) = self + let (cursor, mut workers) = self .worker_service .find_metadata( component_id, @@ -434,6 +435,8 @@ impl WorkerApi { ) .await?; + redact_worker_metadata_for_external(&mut workers); + Ok(Json(model::WorkersMetadataResponse { workers, cursor })) } @@ -490,7 +493,7 @@ impl WorkerApi { params: WorkersMetadataRequest, auth: AuthCtx, ) -> Result> { - let (cursor, workers) = self + let (cursor, mut workers) = self .worker_service .find_metadata( component_id, @@ -502,6 +505,8 @@ impl WorkerApi { ) .await?; + redact_worker_metadata_for_external(&mut workers); + Ok(Json(model::WorkersMetadataResponse { workers, cursor })) } @@ -632,7 +637,7 @@ impl WorkerApi { query: Option, auth: AuthCtx, ) -> Result> { - let response = match (from, query) { + let mut response = match (from, query) { (Some(_), Some(_)) => { return Err(ApiEndpointError::bad_request( api::error_code::INVALID_OPLOG_QUERY_PARAMS, @@ -658,6 +663,10 @@ impl WorkerApi { } }; + for entry in &mut response.entries { + entry.entry.redact_host_managed_values_for_external(); + } + Ok(Json(response)) } @@ -1136,6 +1145,12 @@ impl WorkerApi { } } +fn redact_worker_metadata_for_external(workers: &mut [AgentMetadataDto]) { + for worker in workers { + worker.redact_host_managed_values_for_external(); + } +} + fn normalize_agent_name_with_latest_component( component_id: ComponentId, agent_id: &str, diff --git a/golem-worker-service/src/custom_api/openapi/http_openapi_spec.rs b/golem-worker-service/src/custom_api/openapi/http_openapi_spec.rs index e309752969..5ebff85a3a 100644 --- a/golem-worker-service/src/custom_api/openapi/http_openapi_spec.rs +++ b/golem-worker-service/src/custom_api/openapi/http_openapi_spec.rs @@ -13,7 +13,7 @@ //! Emits the OpenAPI 3.1 document for a deployed HTTP API as a //! [`serde_json::Value`]. //! -//! All `SchemaType` rendering goes through [`render_schema`] (the Wave-1 +//! All request `SchemaType` rendering goes through [`render_input_schema`] (the Wave-1 //! renderer); named types lowered from the routes are emitted once into //! `components/schemas`. The legacy compiled-route schema types are touched //! only by the boundary adapter [`build_document_schema`]. @@ -23,7 +23,7 @@ use super::response_schema::{ }; use super::route_schema::{RequestBodyModel, RouteSchema, build_document_schema}; use super::schema_mapping::{ - arbitrary_binary_schema, render_schema, string_enum_schema, string_schema, + arbitrary_binary_schema, render_input_schema, string_enum_schema, string_schema, }; use crate::custom_api::{RichCompiledRoute, RichRouteBehaviour, RichRouteSecurity}; use golem_common::model::domain_registration::Domain; @@ -140,7 +140,7 @@ fn add_route_parameters( RichRouteBehaviour::CallAgent(_) => { if let Some(call_agent) = &route_schema.call_agent { for param in &call_agent.path_params { - let mut schema = render_schema(graph, ¶m.schema, components)?; + let mut schema = render_input_schema(graph, ¶m.schema, components)?; if param.is_catchall { set_schema_description( &mut schema, @@ -150,11 +150,11 @@ fn add_route_parameters( parameters.push(path_parameter(¶m.name, schema)); } for param in &call_agent.query_params { - let schema = render_schema(graph, ¶m.schema, components)?; + let schema = render_input_schema(graph, ¶m.schema, components)?; parameters.push(query_parameter(¶m.name, param.required, schema)); } for param in &call_agent.header_params { - let schema = render_schema(graph, ¶m.schema, components)?; + let schema = render_input_schema(graph, ¶m.schema, components)?; parameters.push(header_parameter(¶m.name, param.required, schema)); } } @@ -187,7 +187,7 @@ fn build_request_body( Ok(match body { RequestBodyModel::Unused => None, RequestBodyModel::Json(ty) => { - let schema = render_schema(graph, ty, components)?; + let schema = render_input_schema(graph, ty, components)?; Some(request_body_value( "JSON body", vec![("application/json".to_string(), schema)], diff --git a/golem-worker-service/src/custom_api/openapi/response_schema.rs b/golem-worker-service/src/custom_api/openapi/response_schema.rs index 97c770e6bf..a526a346eb 100644 --- a/golem-worker-service/src/custom_api/openapi/response_schema.rs +++ b/golem-worker-service/src/custom_api/openapi/response_schema.rs @@ -16,12 +16,12 @@ //! `unit` → 204, `option` → 200 + 404, `result` → 200 / 500, //! `Text` → `text/plain` + `Content-Language`, `Binary` → selected media type, //! everything else → `application/json`. Schema bodies are rendered from the -//! schema model via [`render_schema`]; CORS / webhook / OpenAPI-spec / OIDC +//! schema model via [`render_output_schema`]; CORS / webhook / OpenAPI-spec / OIDC //! routes carry no agent schema and produce fixed responses/headers. use super::route_schema::{ResponseModel, RouteSchema}; use super::schema_mapping::{ - arbitrary_binary_schema, render_schema, string_enum_schema, string_schema, + arbitrary_binary_schema, render_output_schema, string_enum_schema, string_schema, }; use crate::custom_api::{RichCompiledRoute, RichRouteBehaviour}; use golem_common::base_model::agent::HttpMethod; @@ -192,7 +192,7 @@ fn classify_single_response( match resolve_top_ref(graph, ty) { SchemaType::Option { inner, .. } => { - let schema = render_schema(graph, inner, components)?; + let schema = render_output_schema(graph, inner, components)?; responses.insert( 200, ResponseBodyOpenApiSchema::Known { @@ -205,7 +205,7 @@ fn classify_single_response( SchemaType::Result { spec, .. } => { match &spec.ok { Some(ok) => { - let schema = render_schema(graph, ok, components)?; + let schema = render_output_schema(graph, ok, components)?; responses.insert( 200, ResponseBodyOpenApiSchema::Known { @@ -222,7 +222,7 @@ fn classify_single_response( } match &spec.err { Some(err) => { - let schema = render_schema(graph, err, components)?; + let schema = render_output_schema(graph, err, components)?; responses.insert( 500, ResponseBodyOpenApiSchema::Known { @@ -247,7 +247,7 @@ fn classify_single_response( _ => { // Render the original `ty` (not the ref-resolved body) so a named // type keeps its `$ref` and is emitted once into components. - let schema = render_schema(graph, ty, components)?; + let schema = render_output_schema(graph, ty, components)?; responses.insert( 200, ResponseBodyOpenApiSchema::Known { diff --git a/golem-worker-service/src/custom_api/openapi/schema_mapping.rs b/golem-worker-service/src/custom_api/openapi/schema_mapping.rs index 58ba5f3298..085d870dad 100644 --- a/golem-worker-service/src/custom_api/openapi/schema_mapping.rs +++ b/golem-worker-service/src/custom_api/openapi/schema_mapping.rs @@ -12,33 +12,39 @@ //! Thin JSON bridge between the schema model and the OpenAPI 3.1 document. //! -//! All `SchemaType` rendering goes through the Wave-1 renderer -//! [`to_openapi_components`]; this module only splits the renderer's bundle -//! into the inline root schema and the document-wide `components/schemas` -//! entries, and provides the handful of fixed JSON schemas the emitter needs -//! for non-schema-bearing bodies/headers (binary bodies, enum'd strings, …). +//! All `SchemaType` rendering goes through the Wave-1 renderer. This module +//! selects the external input/output capability policy, splits the renderer's +//! bundle into the inline root schema and document-wide `components/schemas` +//! entries, and provides the handful of fixed JSON schemas the emitter needs. use golem_common::schema::graph::SchemaGraph; -use golem_common::schema::render::to_openapi_components; +use golem_common::schema::render::{ + to_external_input_openapi_components, to_external_output_openapi_components, +}; use golem_common::schema::schema_type::SchemaType; use serde_json::{Map, Value, json}; -/// Render `(graph, ty)` to an OpenAPI 3.1 schema JSON value, merging every -/// named component schema it references into the document-wide -/// `components/schemas` accumulator. -/// -/// Returns the inline root schema for `ty` (a `{ "$ref": … }` object when `ty` -/// is a named `Ref`). Named definitions reachable from `ty` — including the -/// synthesised per-union-branch schemas — are merged into `components` under -/// their `TypeId` keys: an identical entry is deduplicated, a conflicting one -/// is an error. The document-wide schema graph builder disambiguates names, -/// so a real conflict here indicates a bug. -pub fn render_schema( +/// Render a schema for an untrusted external request. Host-managed capability +/// leaves are unsatisfiable because callers cannot construct them. +pub fn render_input_schema( graph: &SchemaGraph, ty: &SchemaType, components: &mut Map, ) -> Result { - let bundle = to_openapi_components(graph, ty); + merge_bundle(to_external_input_openapi_components(graph, ty), components) +} + +/// Render a schema for an externally visible response. Host-managed +/// capability leaves expose only their redacted placeholder. +pub fn render_output_schema( + graph: &SchemaGraph, + ty: &SchemaType, + components: &mut Map, +) -> Result { + merge_bundle(to_external_output_openapi_components(graph, ty), components) +} + +fn merge_bundle(bundle: Value, components: &mut Map) -> Result { let Value::Object(mut bundle) = bundle else { return Err("OpenAPI renderer returned a non-object bundle".to_string()); }; @@ -119,26 +125,65 @@ mod tests { fn scalar_produces_no_components() { let graph = SchemaGraph::anonymous(SchemaType::bool()); let mut components = Map::new(); - let root = render_schema(&graph, &SchemaType::string(), &mut components).unwrap(); + let root = render_input_schema(&graph, &SchemaType::string(), &mut components).unwrap(); assert_eq!(root, json!({ "type": "string" })); assert!(components.is_empty()); } + #[test] + fn external_input_and_output_components_are_directionally_namespaced() { + let capability_id = TypeId::new("app.Capability"); + let graph = SchemaGraph { + defs: vec![SchemaTypeDef { + id: capability_id.clone(), + name: Some("Capability".to_string()), + body: SchemaType::record(vec![NamedFieldType { + name: "credential".to_string(), + body: SchemaType::secret(Default::default()), + metadata: Default::default(), + }]), + }], + root: SchemaType::bool(), + }; + let root = SchemaType::ref_to(capability_id); + let mut components = Map::new(); + + let input = render_input_schema(&graph, &root, &mut components).unwrap(); + let output = render_output_schema(&graph, &root, &mut components).unwrap(); + + assert_eq!( + input["$ref"], + json!("#/components/schemas/Input_app.Capability") + ); + assert_eq!( + output["$ref"], + json!("#/components/schemas/Output_app.Capability") + ); + assert_eq!( + components["Input_app.Capability"]["properties"]["credential"]["not"], + json!({}) + ); + assert_eq!( + components["Output_app.Capability"]["properties"]["credential"]["const"], + json!("") + ); + } + #[test] fn ref_emits_component_and_ref_root() { let (graph, id) = user_graph(); let mut components = Map::new(); - let root = render_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap(); - assert_eq!(root["$ref"], json!("#/components/schemas/app.User")); - assert!(components.contains_key("app.User")); + let root = render_input_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap(); + assert_eq!(root["$ref"], json!("#/components/schemas/Input_app.User")); + assert!(components.contains_key("Input_app.User")); } #[test] fn identical_component_is_deduplicated() { let (graph, id) = user_graph(); let mut components = Map::new(); - render_schema(&graph, &SchemaType::ref_to(id.clone()), &mut components).unwrap(); - render_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap(); + render_input_schema(&graph, &SchemaType::ref_to(id.clone()), &mut components).unwrap(); + render_input_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap(); assert_eq!(components.len(), 1); } @@ -146,8 +191,9 @@ mod tests { fn conflicting_component_errors() { let (graph, id) = user_graph(); let mut components = Map::new(); - components.insert("app.User".to_string(), json!({ "type": "string" })); - let err = render_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap_err(); + components.insert("Input_app.User".to_string(), json!({ "type": "string" })); + let err = + render_input_schema(&graph, &SchemaType::ref_to(id), &mut components).unwrap_err(); assert!( err.contains("conflicting component schema"), "unexpected error: {err}" diff --git a/golem-worker-service/src/custom_api/openapi/tests.rs b/golem-worker-service/src/custom_api/openapi/tests.rs index d12a9066b5..3826f18b86 100644 --- a/golem-worker-service/src/custom_api/openapi/tests.rs +++ b/golem-worker-service/src/custom_api/openapi/tests.rs @@ -999,9 +999,9 @@ fn openapi_spec_route_returns_object_with_additional_properties() { // -------------------------------------------------------------------------- #[test] -fn named_type_shared_across_routes_appears_once_in_components() { +fn named_type_shared_across_routes_appears_once_per_direction_in_components() { // A named record used as both a request body and a response should appear - // exactly once in components/schemas, referenced by `$ref`. + // once per direction in components/schemas, referenced by `$ref`. let named = SchemaGraph { defs: vec![SchemaTypeDef { id: TypeId("User".to_string()), @@ -1043,8 +1043,8 @@ fn named_type_shared_across_routes_appears_once_in_components() { .collect(); assert_eq!( user_keys.len(), - 1, - "named type should appear exactly once, got keys: {:?}", + 2, + "named type should appear once per direction, got keys: {:?}", schemas.keys().collect::>() ); @@ -1055,13 +1055,19 @@ fn named_type_shared_across_routes_appears_once_in_components() { body_schema["$ref"] .as_str() .unwrap() - .starts_with("#/components/schemas/"), + .starts_with("#/components/schemas/Input_"), "request body should reference the component, got: {body_schema}" ); - // The response on the other route references the same component. + // The response references the output component for the same named type. let response_schema = &spec["paths"]["/b"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - assert_eq!(response_schema["$ref"], body_schema["$ref"]); + assert_eq!( + response_schema["$ref"], + body_schema["$ref"] + .as_str() + .unwrap() + .replacen("/Input_", "/Output_", 1) + ); } #[test] diff --git a/golem-worker-service/src/custom_api/rich_request.rs b/golem-worker-service/src/custom_api/rich_request.rs index b313f0c0b0..250e4b93a3 100644 --- a/golem-worker-service/src/custom_api/rich_request.rs +++ b/golem-worker-service/src/custom_api/rich_request.rs @@ -22,7 +22,7 @@ use golem_common::model::invocation_context::{ }; use golem_common::model::{IdempotencyKey, invocation_context}; use golem_common::schema::SchemaGraph; -use golem_common::schema::render::from_json_value; +use golem_common::schema::render::from_untrusted_json_value; use golem_common::schema::unstructured::{binary_body_restrictions, text_body_restrictions}; use golem_service_base::custom_api::RequestBodySchema; use golem_service_base::headers::TraceContextHeaders; @@ -148,11 +148,10 @@ impl RichRequest { error: err.to_string(), })?; let parsed_body = - from_json_value(&expected.graph, &expected.graph.root, &json_body).map_err( - |err| RequestHandlerError::JsonBodyParsingFailed { + from_untrusted_json_value(&expected.graph, &expected.graph.root, &json_body) + .map_err(|err| RequestHandlerError::JsonBodyParsingFailed { errors: vec![err.to_string()], - }, - )?; + })?; Ok(ParsedRequestBody::JsonBody(parsed_body)) } @@ -572,6 +571,21 @@ mod request_body_tests { assert!(let RequestHandlerError::JsonBodyParsingFailed { .. } = err); } + #[test] + async fn json_body_rejects_host_managed_capability_values() { + let mut request = json_request(json!("forged")); + let schema = json_body(SchemaType::secret(Default::default())); + + let err = request.parse_request_body(&schema).await.unwrap_err(); + + let_assert!(RequestHandlerError::JsonBodyParsingFailed { errors } = err); + assert!( + errors + .iter() + .any(|error| error.contains("host-managed capability `secret`")) + ); + } + #[test] async fn restricted_binary_body_accepts_allowed_mime_type() { let mut request = raw_request_with_content_type(b"binary-data", "application/octet-stream"); diff --git a/golem-worker-service/src/mcp/invoke/agent_method_input.rs b/golem-worker-service/src/mcp/invoke/agent_method_input.rs index 7f320b0b46..78b97ae03a 100644 --- a/golem-worker-service/src/mcp/invoke/agent_method_input.rs +++ b/golem-worker-service/src/mcp/invoke/agent_method_input.rs @@ -17,7 +17,7 @@ use crate::mcp::invoke::{schema_binary_value_from_json, schema_text_value_from_j use golem_common::schema::agent::{FieldSource, InputSchema, NamedField}; use golem_common::schema::graph::SchemaGraph; use golem_common::schema::multimodal::multimodal_variant_cases; -use golem_common::schema::render::json_value::from_json_value; +use golem_common::schema::render::json_value::from_untrusted_json_value; use golem_common::schema::schema_type::{SchemaType, VariantCaseType}; use golem_common::schema::schema_value::{SchemaValue, VariantValuePayload}; use rmcp::model::JsonObject; @@ -148,7 +148,7 @@ fn extract_single_field_value( } } }; - from_json_value(graph, &field.schema, &json_value) + from_untrusted_json_value(graph, &field.schema, &json_value) .map_err(|e| format!("Failed to parse parameter '{}': {}", name, e)) } } @@ -265,6 +265,21 @@ mod tests { assert!(err.contains("Missing parameter: city"), "got: {err}"); } + #[test] + fn rejects_host_managed_method_parameters() { + let schema = input(vec![NamedField::user_supplied( + "credential", + SchemaType::secret(Default::default()), + )]); + let args: JsonObject = json!({"credential": "forged"}).as_object().unwrap().clone(); + + let err = get_agent_method_input(&args, &graph(), &schema).unwrap_err(); + assert!( + err.contains("host-managed capability `secret`"), + "got: {err}" + ); + } + #[test] fn error_on_invalid_base64() { let schema = input(vec![binary_field("image")]); diff --git a/golem-worker-service/src/mcp/invoke/constructor_param_extraction.rs b/golem-worker-service/src/mcp/invoke/constructor_param_extraction.rs index f7bd807809..c46302627d 100644 --- a/golem-worker-service/src/mcp/invoke/constructor_param_extraction.rs +++ b/golem-worker-service/src/mcp/invoke/constructor_param_extraction.rs @@ -14,8 +14,9 @@ use golem_common::schema::agent::{FieldSource, InputSchema, NamedField}; use golem_common::schema::graph::SchemaGraph; +use golem_common::schema::host_managed::find_host_managed_type; use golem_common::schema::multimodal::multimodal_variant_cases; -use golem_common::schema::render::json_value::from_json_value; +use golem_common::schema::render::json_value::from_untrusted_json_value; use golem_common::schema::schema_type::SchemaType; use golem_common::schema::schema_value::SchemaValue; @@ -65,7 +66,7 @@ pub fn extract_constructor_input_values( } }; - let value = from_json_value(graph, &field.schema, &json_value) + let value = from_untrusted_json_value(graph, &field.schema, &json_value) .map_err(|e| format!("Failed to parse parameter '{}': {}", field.name, e))?; params.push(value); } @@ -94,6 +95,15 @@ fn reject_multimodal(graph: &SchemaGraph, fields: &[&NamedField]) -> Result<(), /// Reject unstructured (text/binary) constructor parameters, which cannot be /// supplied through the MCP agent-id encoding. fn ensure_supplyable_via_mcp(graph: &SchemaGraph, field: &NamedField) -> Result<(), String> { + if let Some(found) = find_host_managed_type(graph, &field.schema).map_err(|e| e.to_string())? { + return Err(format!( + "MCP cannot support host-managed {} constructor parameter '{}' at {}", + found.kind.kind_name(), + field.name, + found.path + )); + } + match graph .resolve_ref(&field.schema) .map_err(|e| e.to_string())? @@ -194,4 +204,16 @@ mod tests { let err = extract_constructor_input_values(&args, &graph(), &schema).unwrap_err(); assert!(err.contains("multimodal"), "got: {err}"); } + + #[test] + fn rejects_host_managed_constructor_schema_before_reading_arguments() { + let schema = input(vec![NamedField::user_supplied( + "credential", + SchemaType::secret(Default::default()), + )]); + let args = json!({}).as_object().unwrap().clone(); + + let err = extract_constructor_input_values(&args, &graph(), &schema).unwrap_err(); + assert!(err.contains("host-managed secret"), "got: {err}"); + } } diff --git a/golem-worker-service/src/mcp/invoke/multimodal_params_extraction.rs b/golem-worker-service/src/mcp/invoke/multimodal_params_extraction.rs index 86383ba81e..ed44abf68b 100644 --- a/golem-worker-service/src/mcp/invoke/multimodal_params_extraction.rs +++ b/golem-worker-service/src/mcp/invoke/multimodal_params_extraction.rs @@ -14,7 +14,7 @@ use crate::mcp::invoke::{schema_binary_value_from_json, schema_text_value_from_json}; use golem_common::schema::graph::SchemaGraph; -use golem_common::schema::render::json_value::from_json_value; +use golem_common::schema::render::json_value::from_untrusted_json_value; use golem_common::schema::schema_type::SchemaType; use golem_common::schema::schema_value::SchemaValue; @@ -43,7 +43,7 @@ pub fn extract_multimodal_element_value( schema_binary_value_from_json(value_json, restrictions) .map_err(|e| format!("parts[{}] '{}': {}", index, name, e)) } - _ => from_json_value(graph, case_schema, value_json) + _ => from_untrusted_json_value(graph, case_schema, value_json) .map_err(|e| format!("parts[{}] '{}': failed to parse value: {}", index, name, e)), } } diff --git a/golem-worker-service/src/service/worker/service.rs b/golem-worker-service/src/service/worker/service.rs index 8672aa667d..4b4eb79f4b 100644 --- a/golem-worker-service/src/service/worker/service.rs +++ b/golem-worker-service/src/service/worker/service.rs @@ -2401,7 +2401,7 @@ impl WorkerService { let agent_type = ®istered_agent_type.agent_type; let constructor_parameters = json_input_schema_value_to_typed_schema_value( - request.parameters, + request.parameters.into_inner(), &agent_type.schema, &agent_type.constructor.input_schema, ) @@ -2498,7 +2498,7 @@ impl WorkerService { let agent_type = ®istered_agent_type.agent_type; let constructor_parameters = json_input_schema_value_to_typed_schema_value( - request.parameters, + request.parameters.into_inner(), &agent_type.schema, &agent_type.constructor.input_schema, ) @@ -2593,7 +2593,7 @@ impl WorkerService { })?; let method_parameters = json_input_schema_value_to_typed_schema_value( - request.method_parameters, + request.method_parameters.into_inner(), &invocation_agent_type.schema, &method.input_schema, ) @@ -2682,6 +2682,11 @@ impl WorkerService { .cloned() .unwrap_or_else(|| SchemaType::tuple(Vec::new())); let typed_output = TypedSchemaValue::new(output_graph, output_value); + let typed_output = typed_output.try_into().map_err(|error| { + WorkerServiceError::Internal(format!( + "Agent method result cannot cross the external JSON boundary: {error}" + )) + })?; Ok(AgentInvocationResult { agent_id: response_agent_id, idempotency_key: response_idempotency_key, @@ -2761,8 +2766,8 @@ mod tests { use golem_common::schema::public_json::PublicStreamReference; use golem_common::schema::stream::SchemaValueStream; use golem_common::schema::{ - AgentConstructorSchema, AgentMethodSchema, AgentTypeSchema, InputSchema, NamedField, - OutputSchema, SchemaGraph, SchemaType, SchemaValue, + AgentConstructorSchema, AgentMethodSchema, AgentTypeSchema, ExternalSchemaValue, + InputSchema, NamedField, OutputSchema, SchemaGraph, SchemaType, SchemaValue, }; use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; use golem_service_base::model::auth::AuthCtx; @@ -4047,8 +4052,8 @@ mod tests { } } - fn empty_json_tuple() -> SchemaValue { - SchemaValue::Record { fields: vec![] } + fn empty_json_tuple() -> ExternalSchemaValue { + ExternalSchemaValue::try_from(SchemaValue::Record { fields: vec![] }).unwrap() } fn test_card() -> StoredCard { diff --git a/integration-tests/tests/agent_config/shared_agent_config_live_mutation.rs b/integration-tests/tests/agent_config/shared_agent_config_live_mutation.rs index 4b797c73aa..b6f0189151 100644 --- a/integration-tests/tests/agent_config/shared_agent_config_live_mutation.rs +++ b/integration-tests/tests/agent_config/shared_agent_config_live_mutation.rs @@ -17,12 +17,12 @@ use crate::Tracing; use anyhow::anyhow; use assert2::let_assert; use golem_client::api::{AgentError, RegistryServiceClient}; -use golem_client::model::AgentSecretCreation; -use golem_common::model::agent_secret::{AgentSecretPath, AgentSecretUpdate}; +use golem_client::model::{AgentSecretCreation, AgentSecretUpdate}; +use golem_common::model::agent_secret::AgentSecretPath; use golem_common::model::deployment::DeploymentAgentSecretDefault; use golem_common::model::optional_field_update::OptionalFieldUpdate; use golem_common::model::{AgentStatus, PromiseId}; -use golem_common::schema::{FromSchema, SchemaGraph, SchemaType, SchemaValue}; +use golem_common::schema::{ExternalSchemaValue, FromSchema, SchemaGraph, SchemaType, SchemaValue}; use golem_common::{agent_id, data_value}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; @@ -123,7 +123,9 @@ async fn agent_reads_updated_environment_secret( &secret.id.0, &AgentSecretUpdate { current_revision: secret.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::String("bar".to_string())), + secret_value: OptionalFieldUpdate::Set( + ExternalSchemaValue::try_from(SchemaValue::String("bar".to_string())).unwrap(), + ), }, ) .await?; @@ -243,7 +245,9 @@ async fn repeated_secret_reveal_invocation_replays_pinned_original_value( &secret.id.0, &AgentSecretUpdate { current_revision: secret.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::String("bar".to_string())), + secret_value: OptionalFieldUpdate::Set( + ExternalSchemaValue::try_from(SchemaValue::String("bar".to_string())).unwrap(), + ), }, ) .await?; @@ -433,7 +437,9 @@ async fn agent_reads_recreated_environment_secret( &AgentSecretCreation { path: AgentSecretPath(secret_path.clone()), secret_type: SchemaGraph::anonymous(SchemaType::string()), - secret_value: Some(SchemaValue::String("bar".to_string())), + secret_value: Some( + ExternalSchemaValue::try_from(SchemaValue::String("bar".to_string())).unwrap(), + ), }, ) .await?; @@ -509,7 +515,9 @@ async fn agent_reads_secret_after_canonicalized_update( &secret.id.0, &AgentSecretUpdate { current_revision: secret.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::String("bar".to_string())), + secret_value: OptionalFieldUpdate::Set( + ExternalSchemaValue::try_from(SchemaValue::String("bar".to_string())).unwrap(), + ), }, ) .await?; diff --git a/integration-tests/tests/api/agent_secret.rs b/integration-tests/tests/api/agent_secret.rs index c530aff6e4..a74f9e0c43 100644 --- a/integration-tests/tests/api/agent_secret.rs +++ b/integration-tests/tests/api/agent_secret.rs @@ -16,12 +16,12 @@ use golem_client::api::{ RegistryServiceClient, RegistryServiceCreateAgentSecretError, RegistryServiceDeleteAgentSecretError, RegistryServiceUpdateAgentSecretError, }; +use golem_client::model::{AgentSecretCreation, AgentSecretUpdate}; use golem_common::model::agent_secret::{ - AgentSecretCreation, AgentSecretPath, AgentSecretRevision, AgentSecretUpdate, - CanonicalAgentSecretPath, + AgentSecretPath, AgentSecretRevision, CanonicalAgentSecretPath, }; use golem_common::model::optional_field_update::OptionalFieldUpdate; -use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; +use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaType, SchemaValue}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::TestDslExtended; use pretty_assertions::assert_eq; @@ -30,6 +30,10 @@ use test_r::{inherit_test_dep, test}; inherit_test_dep!(EnvBasedTestDependencies); +fn external(value: SchemaValue) -> ExternalSchemaValue { + ExternalSchemaValue::try_from(value).unwrap() +} + #[test] #[tracing::instrument] async fn create_agent_secret_with_value(deps: &EnvBasedTestDependencies) -> anyhow::Result<()> { @@ -41,7 +45,7 @@ async fn create_agent_secret_with_value(deps: &EnvBasedTestDependencies) -> anyh let creation = AgentSecretCreation { path: AgentSecretPath(vec!["foo".to_string(), "bar".to_string()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let result = client.create_agent_secret(&env.id.0, &creation).await?; @@ -84,7 +88,7 @@ async fn secret_path_is_canonicalized_when_reading( "third_path_segment".to_string(), ]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let result = client.create_agent_secret(&env.id.0, &creation).await?; @@ -145,7 +149,7 @@ async fn creating_same_path_twice_should_fail( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["dup".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; client.create_agent_secret(&env.id.0, &creation).await?; @@ -176,7 +180,7 @@ async fn creating_same_path_in_different_casing_should_fail( &AgentSecretCreation { path: AgentSecretPath(vec!["secret_path".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }, ) .await?; @@ -187,7 +191,7 @@ async fn creating_same_path_in_different_casing_should_fail( &AgentSecretCreation { path: AgentSecretPath(vec!["secretPath".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }, ) .await; @@ -211,7 +215,7 @@ async fn update_secret_increments_revision(deps: &EnvBasedTestDependencies) -> a let creation = AgentSecretCreation { path: AgentSecretPath(vec!["rev".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -221,12 +225,15 @@ async fn update_secret_increments_revision(deps: &EnvBasedTestDependencies) -> a &created.id.0, &AgentSecretUpdate { current_revision: created.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::Bool(false)), + secret_value: OptionalFieldUpdate::Set(external(SchemaValue::Bool(false))), }, ) .await?; - assert_eq!(updated.secret_value, Some(SchemaValue::Bool(false))); + assert_eq!( + updated.secret_value.map(ExternalSchemaValue::into_inner), + Some(SchemaValue::Bool(false)) + ); assert!(updated.revision > created.revision); Ok(()) @@ -243,7 +250,7 @@ async fn update_with_stale_revision_should_fail( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["stale".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -253,7 +260,7 @@ async fn update_with_stale_revision_should_fail( &created.id.0, &AgentSecretUpdate { current_revision: created.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::Bool(false)), + secret_value: OptionalFieldUpdate::Set(external(SchemaValue::Bool(false))), }, ) .await?; @@ -263,7 +270,7 @@ async fn update_with_stale_revision_should_fail( &created.id.0, &AgentSecretUpdate { current_revision: created.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::Bool(true)), + secret_value: OptionalFieldUpdate::Set(external(SchemaValue::Bool(true))), }, ) .await; @@ -287,7 +294,7 @@ async fn unset_secret_value(deps: &EnvBasedTestDependencies) -> anyhow::Result<( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["unset".into()]), secret_type: SchemaGraph::anonymous(SchemaType::string()), - secret_value: Some(SchemaValue::String("hello".to_string())), + secret_value: Some(external(SchemaValue::String("hello".to_string()))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -317,7 +324,7 @@ async fn delete_secret(deps: &EnvBasedTestDependencies) -> anyhow::Result<()> { let creation = AgentSecretCreation { path: AgentSecretPath(vec!["delete".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -340,7 +347,7 @@ async fn delete_with_stale_revision_should_fail( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["delete-stale".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -348,9 +355,9 @@ async fn delete_with_stale_revision_should_fail( client .update_agent_secret( &created.id.0, - &golem_common::model::agent_secret::AgentSecretUpdate { + &AgentSecretUpdate { current_revision: created.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::Bool(false)), + secret_value: OptionalFieldUpdate::Set(external(SchemaValue::Bool(false))), }, ) .await?; @@ -378,7 +385,7 @@ async fn delete_and_recreate_same_path(deps: &EnvBasedTestDependencies) -> anyho let creation = AgentSecretCreation { path: AgentSecretPath(vec!["recreate".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; @@ -407,7 +414,7 @@ async fn create_agent_secret_with_value_type_mismatch_should_fail( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["type".into(), "creation-mismatch".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::String("not-a-bool".to_string())), + secret_value: Some(external(SchemaValue::String("not-a-bool".to_string()))), }; let result = client.create_agent_secret(&env.id.0, &creation).await; @@ -439,14 +446,16 @@ async fn update_agent_secret_with_wrong_type_should_fail( let creation = AgentSecretCreation { path: AgentSecretPath(vec!["update".into(), "type-mismatch".into()]), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(true)), + secret_value: Some(external(SchemaValue::Bool(true))), }; let created = client.create_agent_secret(&env.id.0, &creation).await?; let update = AgentSecretUpdate { current_revision: created.revision, - secret_value: OptionalFieldUpdate::Set(SchemaValue::String("not-a-bool".to_string())), + secret_value: OptionalFieldUpdate::Set(external(SchemaValue::String( + "not-a-bool".to_string(), + ))), }; let result = client.update_agent_secret(&created.id.0, &update).await; diff --git a/integration-tests/tests/api/deployment.rs b/integration-tests/tests/api/deployment.rs index 5b41775670..e20cf62279 100644 --- a/integration-tests/tests/api/deployment.rs +++ b/integration-tests/tests/api/deployment.rs @@ -16,11 +16,10 @@ use golem_client::api::{ RegistryServiceClient, RegistryServiceDeployEnvironmentError, RegistryServiceGetToolReleaseError, RegistryServiceRollbackEnvironmentError, }; +use golem_client::model::AgentSecretCreation; use golem_client::model::DeploymentCreation; use golem_common::model::agent::AgentTypeName; -use golem_common::model::agent_secret::{ - AgentSecretCreation, AgentSecretPath, CanonicalAgentSecretPath, -}; +use golem_common::model::agent_secret::{AgentSecretPath, CanonicalAgentSecretPath}; use golem_common::model::component::{ AgentTypeProvisionConfigUpdate, ComponentCreation, ComponentName, ComponentUpdate, ToolDeploymentConfigCreation, ToolDeploymentConfigUpdate, ToolProvisionConfigCreation, @@ -54,7 +53,7 @@ use golem_common::schema::tool::{ CommandBody, CommandNode, CommandTree, Doc, Globals, Positionals, Tool, }; use golem_common::schema::validation::is_equivalent_cross_graph; -use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; +use golem_common::schema::{ExternalSchemaValue, SchemaGraph, SchemaType, SchemaValue}; use golem_common::{agent_id, data_value}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; @@ -202,6 +201,10 @@ fn deployment_creation( } } +fn external(value: SchemaValue) -> ExternalSchemaValue { + ExternalSchemaValue::try_from(value).unwrap() +} + fn assert_secret_type_is_string(secret_type: &SchemaGraph) { let expected = SchemaGraph::anonymous(SchemaType::string()); assert!(is_equivalent_cross_graph( @@ -815,8 +818,11 @@ async fn deploy_creates_missing_secret_from_default( assert_eq!(secret.path.0, secret_path); assert_secret_type_is_string(&secret.secret_type); assert_eq!( - secret.secret_value, - Some(SchemaValue::String("foo".to_string())) + secret + .secret_value + .as_ref() + .map(ExternalSchemaValue::as_inner), + Some(&SchemaValue::String("foo".to_string())) ); Ok(()) @@ -839,7 +845,7 @@ async fn deploy_ignores_default_if_secret_already_exists( &AgentSecretCreation { path: AgentSecretPath(secret_path.clone()), secret_type: SchemaGraph::anonymous(SchemaType::string()), - secret_value: Some(SchemaValue::String("bar".to_string())), + secret_value: Some(external(SchemaValue::String("bar".to_string()))), }, ) .await?; @@ -885,8 +891,11 @@ async fn deploy_ignores_default_if_secret_already_exists( // Existing value must be preserved assert_eq!( - secret.secret_value, - Some(SchemaValue::String("bar".to_string())) + secret + .secret_value + .as_ref() + .map(ExternalSchemaValue::as_inner), + Some(&SchemaValue::String("bar".to_string())) ); Ok(()) @@ -954,8 +963,11 @@ async fn deploy_uses_default_if_secret_already_exists_with_no_value( assert_secret_type_is_string(&secret.secret_type); assert_eq!( - secret.secret_value, - Some(SchemaValue::String("foo".to_string())) + secret + .secret_value + .as_ref() + .map(ExternalSchemaValue::as_inner), + Some(&SchemaValue::String("foo".to_string())) ); Ok(()) @@ -978,7 +990,7 @@ async fn deploy_fails_if_existing_secret_type_mismatches_default( &AgentSecretCreation { path: AgentSecretPath(secret_path.clone()), secret_type: SchemaGraph::anonymous(SchemaType::bool()), - secret_value: Some(SchemaValue::Bool(false)), + secret_value: Some(external(SchemaValue::Bool(false))), }, ) .await?; diff --git a/integration-tests/tests/capabilities.rs b/integration-tests/tests/capabilities.rs index 91824ea35f..b0f4049bf4 100644 --- a/integration-tests/tests/capabilities.rs +++ b/integration-tests/tests/capabilities.rs @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! End-to-end round-trip of host-managed capability schema types through SDK -> -//! REST/RPC -> executor, asserting that capability material is reconstructed on -//! the receiver side while the rendering surfaces redact it (CLI display via -//! [`value_to_cli_text`], tracing/`Debug` via [`redacted_schema_value_debug`]). +//! End-to-end RPC round-trip of host-managed capabilities, asserting that +//! received capabilities remain usable while external operation logs redact them. use crate::Tracing; use anyhow::anyhow; @@ -25,10 +23,7 @@ use golem_common::model::oplog::{ }; use golem_common::model::quota::{EnforcementAction, TimePeriod}; use golem_common::model::{AgentId, AgentStatus}; -use golem_common::schema::render::value_to_cli_text; -use golem_common::schema::{ - QuotaTokenSpec, SchemaGraph, SchemaType, SchemaValue, redacted_schema_value_debug, -}; +use golem_common::schema::SchemaValue; use golem_common::{agent_id, data_value}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; @@ -42,9 +37,8 @@ inherit_test_dep!(EnvBasedTestDependencies); const QUOTA_TOKEN_PLACEHOLDER: &str = ""; -/// Returns the raw recorded argument record of the first -/// `AgentInvocationStarted` entry of the given agent method. The get-oplog API -/// is not a redacting surface, so capability values appear here intact. +/// Returns the externally redacted argument record of the first +/// `AgentInvocationStarted` entry of the given agent method. fn started_input_fields<'a>( oplog: &'a [PublicOplogEntryWithIndex], method: &str, @@ -67,8 +61,8 @@ fn started_input_fields<'a>( /// A `QuotaToken` capability split off and sent to a second agent over RPC must /// be reconstructed into a live lease on the receiver side (proven by the -/// receiver successfully reserving and making HTTP calls), while the CLI display -/// and tracing/`Debug` surfaces redact the live token snapshot. +/// receiver successfully reserving and making HTTP calls), while the external +/// operation log redacts the live token snapshot. #[test] #[tracing::instrument] #[timeout("8m")] @@ -175,39 +169,17 @@ async fn quota_token_capability_round_trips_and_is_redacted( "expected 4 total HTTP calls from the shared, reconstructed quota token" ); - // The reconstructed token argument the receiver actually processed is - // recorded as a real `QuotaToken` lease snapshot in its oplog. Confirm the - // token contents were reconstructed on the receiver side... + // The external operation log must not expose the authoritative snapshot + // even though the receiver successfully used the transferred token. let receiver_oplog = user.get_oplog(&receiver, OplogIndex::INITIAL).await?; let input_fields = started_input_fields(&receiver_oplog, "reserve_and_call_in_loop"); let token_value = input_fields .first() .ok_or_else(|| anyhow!("reserve_and_call_in_loop: missing token argument"))?; - match token_value { - SchemaValue::QuotaToken(payload) => { - assert_eq!(payload.resource_name, "cap-rpc-rate"); - assert_eq!(payload.expected_use, 2); - } - other => { - return Err(anyhow!( - "expected a reconstructed QuotaToken, got {other:?}" - )); - } - } - - // ...and that both observability surfaces redact the live token snapshot. - let token_type = SchemaType::quota_token(QuotaTokenSpec::default()); - let graph = SchemaGraph::anonymous(token_type.clone()); - let cli_text = value_to_cli_text(&graph, &token_type, token_value)?; - assert_eq!( - cli_text, QUOTA_TOKEN_PLACEHOLDER, - "reserve_and_call_in_loop: CLI rendering must redact the quota token" - ); - - let debug_text = format!("{:?}", redacted_schema_value_debug(token_value)); assert_eq!( - debug_text, QUOTA_TOKEN_PLACEHOLDER, - "reserve_and_call_in_loop: tracing/Debug rendering must redact the quota token" + token_value, + &SchemaValue::String(QUOTA_TOKEN_PLACEHOLDER.to_string()), + "reserve_and_call_in_loop: external operation log must redact the quota token" ); Ok(()) diff --git a/integration-tests/tests/goldenfiles/expected_openapi_json.json b/integration-tests/tests/goldenfiles/expected_openapi_json.json index 0aa4312c52..db9f2d1a6e 100644 --- a/integration-tests/tests/goldenfiles/expected_openapi_json.json +++ b/integration-tests/tests/goldenfiles/expected_openapi_json.json @@ -1,7 +1,7 @@ { "components": { "schemas": { - "golem_it_agent_sdk_rust.http.model.JsonBodyResponse": { + "Input_golem_it_agent_sdk_rust.http.model.JsonBodyResponse": { "additionalProperties": false, "properties": { "ok": { @@ -14,7 +14,7 @@ "title": "JsonBodyResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.JsonResponse": { + "Input_golem_it_agent_sdk_rust.http.model.JsonResponse": { "additionalProperties": false, "properties": { "value": { @@ -27,7 +27,7 @@ "title": "JsonResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse": { + "Input_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse": { "additionalProperties": false, "properties": { "joined": { @@ -40,7 +40,7 @@ "title": "MultiPathVarsResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.OkResponse": { + "Input_golem_it_agent_sdk_rust.http.model.OkResponse": { "additionalProperties": false, "properties": { "ok": { @@ -53,7 +53,7 @@ "title": "OkResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.OptionalResponse": { + "Input_golem_it_agent_sdk_rust.http.model.OptionalResponse": { "additionalProperties": false, "properties": { "value": { @@ -66,7 +66,7 @@ "title": "OptionalResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse": { + "Input_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse": { "additionalProperties": false, "properties": { "request_id": { @@ -83,7 +83,7 @@ "title": "PathAndHeaderResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.PathAndQueryResponse": { + "Input_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse": { "additionalProperties": false, "properties": { "id": { @@ -102,7 +102,7 @@ "title": "PathAndQueryResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.PreflightRequest": { + "Input_golem_it_agent_sdk_rust.http.model.PreflightRequest": { "additionalProperties": false, "properties": { "name": { @@ -115,7 +115,7 @@ "title": "PreflightRequest", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.PreflightResponse": { + "Input_golem_it_agent_sdk_rust.http.model.PreflightResponse": { "additionalProperties": false, "properties": { "received": { @@ -128,7 +128,7 @@ "title": "PreflightResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.RemainingPathResponse": { + "Input_golem_it_agent_sdk_rust.http.model.RemainingPathResponse": { "additionalProperties": false, "properties": { "tail": { @@ -141,7 +141,7 @@ "title": "RemainingPathResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.ResourceResponse": { + "Input_golem_it_agent_sdk_rust.http.model.ResourceResponse": { "additionalProperties": false, "properties": { "id": { @@ -162,7 +162,7 @@ "title": "ResourceResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.ResourceUpdate": { + "Input_golem_it_agent_sdk_rust.http.model.ResourceUpdate": { "additionalProperties": false, "properties": { "description": { @@ -200,7 +200,7 @@ "title": "ResourceUpdate", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.ResultErrResponse": { + "Input_golem_it_agent_sdk_rust.http.model.ResultErrResponse": { "additionalProperties": false, "properties": { "error": { @@ -213,7 +213,7 @@ "title": "ResultErrResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.ResultOkResponse": { + "Input_golem_it_agent_sdk_rust.http.model.ResultOkResponse": { "additionalProperties": false, "properties": { "value": { @@ -226,7 +226,7 @@ "title": "ResultOkResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.StringPathVarResponse": { + "Input_golem_it_agent_sdk_rust.http.model.StringPathVarResponse": { "additionalProperties": false, "properties": { "value": { @@ -239,7 +239,260 @@ "title": "StringPathVarResponse", "type": "object" }, - "golem_it_agent_sdk_rust.http.model.WebhookResponse": { + "Input_golem_it_agent_sdk_rust.http.model.WebhookResponse": { + "additionalProperties": false, + "properties": { + "payload_length": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "payload_length" + ], + "title": "WebhookResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.JsonBodyResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "JsonBodyResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.JsonResponse": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "JsonResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse": { + "additionalProperties": false, + "properties": { + "joined": { + "type": "string" + } + }, + "required": [ + "joined" + ], + "title": "MultiPathVarsResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.OkResponse": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "OkResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.OptionalResponse": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "OptionalResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse": { + "additionalProperties": false, + "properties": { + "request_id": { + "type": "string" + }, + "resource_id": { + "type": "string" + } + }, + "required": [ + "resource_id", + "request_id" + ], + "title": "PathAndHeaderResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "limit": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "limit" + ], + "title": "PathAndQueryResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.PreflightRequest": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PreflightRequest", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.PreflightResponse": { + "additionalProperties": false, + "properties": { + "received": { + "type": "string" + } + }, + "required": [ + "received" + ], + "title": "PreflightResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.RemainingPathResponse": { + "additionalProperties": false, + "properties": { + "tail": { + "type": "string" + } + }, + "required": [ + "tail" + ], + "title": "RemainingPathResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.ResourceResponse": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string" + }, + "updated": { + "type": "boolean" + } + }, + "required": [ + "id", + "updated", + "method" + ], + "title": "ResourceResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.ResourceUpdate": { + "additionalProperties": false, + "properties": { + "description": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "enabled": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + } + ] + }, + "name": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + }, + "required": [], + "title": "ResourceUpdate", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse": { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "title": "ResultErrResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "ResultOkResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.StringPathVarResponse": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "StringPathVarResponse", + "type": "object" + }, + "Output_golem_it_agent_sdk_rust.http.model.WebhookResponse": { "additionalProperties": false, "properties": { "payload_length": { @@ -284,7 +537,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.OkResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OkResponse" } } }, @@ -405,7 +658,7 @@ "additionalProperties": false, "properties": { "body": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.PreflightRequest" + "$ref": "#/components/schemas/Input_golem_it_agent_sdk_rust.http.model.PreflightRequest" } }, "required": [ @@ -423,7 +676,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.PreflightResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PreflightResponse" } } }, @@ -454,7 +707,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.OkResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OkResponse" } } }, @@ -600,7 +853,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.JsonBodyResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.JsonBodyResponse" } } }, @@ -653,7 +906,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse" } } }, @@ -706,7 +959,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse" } } }, @@ -762,7 +1015,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.PathAndQueryResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse" } } }, @@ -806,7 +1059,7 @@ "additionalProperties": false, "properties": { "update": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceUpdate" + "$ref": "#/components/schemas/Input_golem_it_agent_sdk_rust.http.model.ResourceUpdate" } }, "required": [ @@ -824,7 +1077,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResourceResponse" } } }, @@ -866,7 +1119,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResourceResponse" } } }, @@ -929,7 +1182,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.JsonResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.JsonResponse" } } }, @@ -995,7 +1248,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.OptionalResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OptionalResponse" } } }, @@ -1040,7 +1293,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultOkResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse" } } }, @@ -1050,7 +1303,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultErrResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse" } } }, @@ -1081,7 +1334,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultOkResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse" } } }, @@ -1118,7 +1371,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultErrResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse" } } }, @@ -1161,7 +1414,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.RemainingPathResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.RemainingPathResponse" } } }, @@ -1259,7 +1512,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.StringPathVarResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.StringPathVarResponse" } } }, @@ -1390,7 +1643,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/golem_it_agent_sdk_rust.http.model.WebhookResponse" + "$ref": "#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.WebhookResponse" } } }, diff --git a/integration-tests/tests/goldenfiles/expected_openapi_yaml.yaml b/integration-tests/tests/goldenfiles/expected_openapi_yaml.yaml index 6b5c64e995..8b2c7ad63f 100644 --- a/integration-tests/tests/goldenfiles/expected_openapi_yaml.yaml +++ b/integration-tests/tests/goldenfiles/expected_openapi_yaml.yaml @@ -1,6 +1,6 @@ components: schemas: - golem_it_agent_sdk_rust.http.model.JsonBodyResponse: + Input_golem_it_agent_sdk_rust.http.model.JsonBodyResponse: additionalProperties: false properties: ok: @@ -9,7 +9,7 @@ components: - ok title: JsonBodyResponse type: object - golem_it_agent_sdk_rust.http.model.JsonResponse: + Input_golem_it_agent_sdk_rust.http.model.JsonResponse: additionalProperties: false properties: value: @@ -18,7 +18,7 @@ components: - value title: JsonResponse type: object - golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse: + Input_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse: additionalProperties: false properties: joined: @@ -27,7 +27,7 @@ components: - joined title: MultiPathVarsResponse type: object - golem_it_agent_sdk_rust.http.model.OkResponse: + Input_golem_it_agent_sdk_rust.http.model.OkResponse: additionalProperties: false properties: ok: @@ -36,7 +36,7 @@ components: - ok title: OkResponse type: object - golem_it_agent_sdk_rust.http.model.OptionalResponse: + Input_golem_it_agent_sdk_rust.http.model.OptionalResponse: additionalProperties: false properties: value: @@ -45,7 +45,7 @@ components: - value title: OptionalResponse type: object - golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse: + Input_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse: additionalProperties: false properties: request_id: @@ -57,7 +57,7 @@ components: - request_id title: PathAndHeaderResponse type: object - golem_it_agent_sdk_rust.http.model.PathAndQueryResponse: + Input_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse: additionalProperties: false properties: id: @@ -71,7 +71,7 @@ components: - limit title: PathAndQueryResponse type: object - golem_it_agent_sdk_rust.http.model.PreflightRequest: + Input_golem_it_agent_sdk_rust.http.model.PreflightRequest: additionalProperties: false properties: name: @@ -80,7 +80,7 @@ components: - name title: PreflightRequest type: object - golem_it_agent_sdk_rust.http.model.PreflightResponse: + Input_golem_it_agent_sdk_rust.http.model.PreflightResponse: additionalProperties: false properties: received: @@ -89,7 +89,7 @@ components: - received title: PreflightResponse type: object - golem_it_agent_sdk_rust.http.model.RemainingPathResponse: + Input_golem_it_agent_sdk_rust.http.model.RemainingPathResponse: additionalProperties: false properties: tail: @@ -98,7 +98,7 @@ components: - tail title: RemainingPathResponse type: object - golem_it_agent_sdk_rust.http.model.ResourceResponse: + Input_golem_it_agent_sdk_rust.http.model.ResourceResponse: additionalProperties: false properties: id: @@ -113,7 +113,7 @@ components: - method title: ResourceResponse type: object - golem_it_agent_sdk_rust.http.model.ResourceUpdate: + Input_golem_it_agent_sdk_rust.http.model.ResourceUpdate: additionalProperties: false properties: description: @@ -131,7 +131,7 @@ components: required: [] title: ResourceUpdate type: object - golem_it_agent_sdk_rust.http.model.ResultErrResponse: + Input_golem_it_agent_sdk_rust.http.model.ResultErrResponse: additionalProperties: false properties: error: @@ -140,7 +140,7 @@ components: - error title: ResultErrResponse type: object - golem_it_agent_sdk_rust.http.model.ResultOkResponse: + Input_golem_it_agent_sdk_rust.http.model.ResultOkResponse: additionalProperties: false properties: value: @@ -149,7 +149,7 @@ components: - value title: ResultOkResponse type: object - golem_it_agent_sdk_rust.http.model.StringPathVarResponse: + Input_golem_it_agent_sdk_rust.http.model.StringPathVarResponse: additionalProperties: false properties: value: @@ -158,7 +158,176 @@ components: - value title: StringPathVarResponse type: object - golem_it_agent_sdk_rust.http.model.WebhookResponse: + Input_golem_it_agent_sdk_rust.http.model.WebhookResponse: + additionalProperties: false + properties: + payload_length: + maximum: 18446744073709551615 + minimum: 0 + type: integer + required: + - payload_length + title: WebhookResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.JsonBodyResponse: + additionalProperties: false + properties: + ok: + type: boolean + required: + - ok + title: JsonBodyResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.JsonResponse: + additionalProperties: false + properties: + value: + type: string + required: + - value + title: JsonResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse: + additionalProperties: false + properties: + joined: + type: string + required: + - joined + title: MultiPathVarsResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.OkResponse: + additionalProperties: false + properties: + ok: + type: boolean + required: + - ok + title: OkResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.OptionalResponse: + additionalProperties: false + properties: + value: + type: string + required: + - value + title: OptionalResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse: + additionalProperties: false + properties: + request_id: + type: string + resource_id: + type: string + required: + - resource_id + - request_id + title: PathAndHeaderResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse: + additionalProperties: false + properties: + id: + type: string + limit: + maximum: 18446744073709551615 + minimum: 0 + type: integer + required: + - id + - limit + title: PathAndQueryResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.PreflightRequest: + additionalProperties: false + properties: + name: + type: string + required: + - name + title: PreflightRequest + type: object + Output_golem_it_agent_sdk_rust.http.model.PreflightResponse: + additionalProperties: false + properties: + received: + type: string + required: + - received + title: PreflightResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.RemainingPathResponse: + additionalProperties: false + properties: + tail: + type: string + required: + - tail + title: RemainingPathResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.ResourceResponse: + additionalProperties: false + properties: + id: + type: string + method: + type: string + updated: + type: boolean + required: + - id + - updated + - method + title: ResourceResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.ResourceUpdate: + additionalProperties: false + properties: + description: + oneOf: + - type: 'null' + - type: string + enabled: + oneOf: + - type: 'null' + - type: boolean + name: + oneOf: + - type: 'null' + - type: string + required: [] + title: ResourceUpdate + type: object + Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse: + additionalProperties: false + properties: + error: + type: string + required: + - error + title: ResultErrResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse: + additionalProperties: false + properties: + value: + type: string + required: + - value + title: ResultOkResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.StringPathVarResponse: + additionalProperties: false + properties: + value: + type: string + required: + - value + title: StringPathVarResponse + type: object + Output_golem_it_agent_sdk_rust.http.model.WebhookResponse: additionalProperties: false properties: payload_length: @@ -192,7 +361,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.OkResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OkResponse' description: Response 200 options: responses: @@ -276,7 +445,7 @@ paths: additionalProperties: false properties: body: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.PreflightRequest' + $ref: '#/components/schemas/Input_golem_it_agent_sdk_rust.http.model.PreflightRequest' required: - body type: object @@ -287,7 +456,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.PreflightResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PreflightResponse' description: Response 200 /cors-agents/{agent-name}/wildcard: get: @@ -307,7 +476,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.OkResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OkResponse' description: Response 200 options: responses: @@ -384,7 +553,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.JsonBodyResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.JsonBodyResponse' description: Response 200 /http-agents/{agent-name}/multi-path-vars/{first}/{second}: get: @@ -420,7 +589,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.MultiPathVarsResponse' description: Response 200 /http-agents/{agent-name}/path-and-header/{resource-id}: get: @@ -456,7 +625,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PathAndHeaderResponse' description: Response 200 /http-agents/{agent-name}/path-and-query/{item-id}: get: @@ -495,7 +664,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.PathAndQueryResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.PathAndQueryResponse' description: Response 200 /http-agents/{agent-name}/resource/{id}: patch: @@ -525,7 +694,7 @@ paths: additionalProperties: false properties: update: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceUpdate' + $ref: '#/components/schemas/Input_golem_it_agent_sdk_rust.http.model.ResourceUpdate' required: - update type: object @@ -536,7 +705,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResourceResponse' description: Response 200 /http-agents/{agent-name}/resource/{id}/partial: patch: @@ -564,7 +733,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResourceResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResourceResponse' description: Response 200 /http-agents/{agent-name}/resp/binary: get: @@ -605,7 +774,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.JsonResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.JsonResponse' description: Response 200 /http-agents/{agent-name}/resp/no-content: get: @@ -649,7 +818,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.OptionalResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.OptionalResponse' description: Response 200 '404': description: Response 404 @@ -679,13 +848,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultOkResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse' description: Response 200 '500': content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultErrResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse' description: Response 500 /http-agents/{agent-name}/resp/result-json-void: get: @@ -705,7 +874,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultOkResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultOkResponse' description: Response 200 '500': description: Response 500 @@ -729,7 +898,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.ResultErrResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.ResultErrResponse' description: Response 500 /http-agents/{agent-name}/rest/{tail}: get: @@ -758,7 +927,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.RemainingPathResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.RemainingPathResponse' description: Response 200 /http-agents/{agent-name}/restricted-unstructured-binary/{bucket}: post: @@ -824,7 +993,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.StringPathVarResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.StringPathVarResponse' description: Response 200 /http-agents/{agent-name}/unrestricted-unstructured-binary/{bucket}: post: @@ -931,7 +1100,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/golem_it_agent_sdk_rust.http.model.WebhookResponse' + $ref: '#/components/schemas/Output_golem_it_agent_sdk_rust.http.model.WebhookResponse' description: Response 200 /webhooks/cors-agent/{promise-id}: post: diff --git a/openapi/golem-registry-service.yaml b/openapi/golem-registry-service.yaml index d6503b5cc1..7c4e8562e4 100644 --- a/openapi/golem-registry-service.yaml +++ b/openapi/golem-registry-service.yaml @@ -10185,7 +10185,7 @@ components: secretType: $ref: '#/components/schemas/SchemaGraph' secretValue: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' AgentSecretDto: type: object title: AgentSecretDto @@ -10213,7 +10213,7 @@ components: secretType: $ref: '#/components/schemas/SchemaGraph' secretValue: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' AgentSecretUpdate: type: object title: AgentSecretUpdate @@ -10224,7 +10224,7 @@ components: type: integer format: uint64 secretValue: - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue' + $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue' AgentTypeInitialPermissions: type: object title: AgentTypeInitialPermissions @@ -12664,6 +12664,486 @@ components: type: string body: type: string + ExternalResultValuePayload: + type: object + oneOf: + - type: object + required: + - tag + properties: + tag: + type: string + enum: + - ok + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + - type: object + required: + - tag + properties: + tag: + type: string + enum: + - err + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + ExternalSchemaValue: + type: object + oneOf: + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - bool + value: + type: boolean + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s8 + value: + type: integer + format: int8 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s16 + value: + type: integer + format: int16 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s32 + value: + type: integer + format: int32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s64 + value: + type: integer + format: int64 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u8 + value: + type: integer + format: uint8 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u16 + value: + type: integer + format: uint16 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u32 + value: + type: integer + format: uint32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u64 + value: + type: integer + format: uint64 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - f32 + value: + type: number + format: float + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - f64 + value: + type: number + format: double + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - char + value: + type: char + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - string + value: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - record + value: + type: object + required: + - fields + properties: + fields: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - variant + value: + $ref: '#/components/schemas/ExternalVariantValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - enum + value: + type: object + required: + - case + properties: + case: + type: integer + format: uint32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - flags + value: + type: object + required: + - bits + properties: + bits: + type: array + items: + type: boolean + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - tuple + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - list + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - fixed-list + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - map + value: + type: object + required: + - entries + properties: + entries: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + maxItems: 2 + minItems: 2 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - option + value: + type: object + properties: + inner: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - result + value: + $ref: '#/components/schemas/ExternalResultValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - text + value: + $ref: '#/components/schemas/TextValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - binary + value: + $ref: '#/components/schemas/BinaryValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - path + value: + type: object + required: + - path + properties: + path: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - url + value: + type: object + required: + - url + properties: + url: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - datetime + value: + type: object + required: + - value + properties: + value: + type: string + format: date-time + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - duration + value: + $ref: '#/components/schemas/DurationValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - quantity + value: + $ref: '#/components/schemas/QuantityValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - union + value: + $ref: '#/components/schemas/ExternalUnionValuePayload' + ExternalUnionValuePayload: + type: object + required: + - tag + - body + properties: + tag: + type: string + body: + $ref: '#/components/schemas/ExternalSchemaValue' + ExternalVariantValuePayload: + type: object + required: + - case + properties: + case: + type: integer + format: uint32 + payload: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true FieldDiscriminator: type: object required: @@ -13593,17 +14073,17 @@ components: env_var: type: string nullable: true - OptionalFieldUpdate_SchemaValue: + OptionalFieldUpdate_ExternalSchemaValue: type: object oneOf: - - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Set' - - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Unset' + - $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Set' + - $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Unset' discriminator: propertyName: op mapping: - set: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Set' - unset: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Unset' - OptionalFieldUpdate_SchemaValue_Set: + set: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Set' + unset: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Unset' + OptionalFieldUpdate_ExternalSchemaValue_Set: type: object required: - op @@ -13614,8 +14094,8 @@ components: enum: - set value: - $ref: '#/components/schemas/SchemaValue' - OptionalFieldUpdate_SchemaValue_Unset: + $ref: '#/components/schemas/ExternalSchemaValue' + OptionalFieldUpdate_ExternalSchemaValue_Unset: type: object required: - op diff --git a/openapi/golem-service.yaml b/openapi/golem-service.yaml index 8ee9ebc2ea..93874266dc 100644 --- a/openapi/golem-service.yaml +++ b/openapi/golem-service.yaml @@ -12396,7 +12396,7 @@ components: agentTypeName: type: string parameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' phantomId: type: string format: uuid @@ -12408,7 +12408,7 @@ components: methodName: type: string methodParameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' mode: $ref: '#/components/schemas/AgentInvocationMode' scheduleAt: @@ -12438,7 +12438,7 @@ components: idempotencyKey: type: string result: - $ref: '#/components/schemas/TypedSchemaValue' + $ref: '#/components/schemas/ExternalTypedSchemaValue' componentRevision: type: integer format: uint64 @@ -13618,7 +13618,7 @@ components: agentTypeName: type: string parameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' phantomId: type: string format: uuid @@ -13812,6 +13812,496 @@ components: required: - key - description + ExternalResultValuePayload: + type: object + oneOf: + - type: object + properties: + tag: + type: string + enum: + - ok + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + required: + - tag + - type: object + properties: + tag: + type: string + enum: + - err + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + required: + - tag + ExternalSchemaValue: + type: object + oneOf: + - type: object + properties: + kind: + type: string + enum: + - bool + value: + type: boolean + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - s8 + value: + type: integer + format: int8 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - s16 + value: + type: integer + format: int16 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - s32 + value: + type: integer + format: int32 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - s64 + value: + type: integer + format: int64 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - u8 + value: + type: integer + format: uint8 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - u16 + value: + type: integer + format: uint16 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - u32 + value: + type: integer + format: uint32 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - u64 + value: + type: integer + format: uint64 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - f32 + value: + type: number + format: float + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - f64 + value: + type: number + format: double + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - char + value: + type: char + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - string + value: + type: string + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - record + value: + type: object + properties: + fields: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - fields + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - variant + value: + $ref: '#/components/schemas/ExternalVariantValuePayload' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - enum + value: + type: object + properties: + case: + type: integer + format: uint32 + required: + - case + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - flags + value: + type: object + properties: + bits: + type: array + items: + type: boolean + required: + - bits + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - tuple + value: + type: object + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - elements + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - list + value: + type: object + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - elements + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - fixed-list + value: + type: object + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - elements + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - map + value: + type: object + properties: + entries: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + minItems: 2 + maxItems: 2 + required: + - entries + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - option + value: + type: object + properties: + inner: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - result + value: + $ref: '#/components/schemas/ExternalResultValuePayload' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - text + value: + $ref: '#/components/schemas/TextValuePayload' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - binary + value: + $ref: '#/components/schemas/BinaryValuePayload' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - path + value: + type: object + properties: + path: + type: string + required: + - path + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - url + value: + type: object + properties: + url: + type: string + required: + - url + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - datetime + value: + type: object + properties: + value: + type: string + format: date-time + required: + - value + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - duration + value: + $ref: '#/components/schemas/DurationValuePayload' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - quantity + value: + $ref: '#/components/schemas/QuantityValue' + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - union + value: + $ref: '#/components/schemas/ExternalUnionValuePayload' + required: + - kind + - value + ExternalTypedSchemaValue: + type: object + properties: + graph: + $ref: '#/components/schemas/SchemaGraph' + value: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - graph + - value + ExternalUnionValuePayload: + type: object + properties: + tag: + type: string + body: + $ref: '#/components/schemas/ExternalSchemaValue' + required: + - tag + - body + ExternalVariantValuePayload: + type: object + properties: + case: + type: integer + format: uint32 + payload: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + required: + - case FailedUpdate: title: FailedUpdate type: object @@ -18889,7 +19379,7 @@ components: secretType: $ref: '#/components/schemas/SchemaGraph' secretValue: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' required: - path - secretType @@ -18914,7 +19404,7 @@ components: secretType: $ref: '#/components/schemas/SchemaGraph' secretValue: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' required: - id - environmentId @@ -18929,7 +19419,7 @@ components: type: integer format: uint64 secretValue: - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue' + $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue' required: - currentRevision AgentTypeInitialPermissions: @@ -21213,17 +21703,17 @@ components: - doc - shape - required - OptionalFieldUpdate_SchemaValue: + OptionalFieldUpdate_ExternalSchemaValue: discriminator: propertyName: op mapping: - set: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Set' - unset: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Unset' + set: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Set' + unset: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Unset' type: object oneOf: - - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Set' - - $ref: '#/components/schemas/OptionalFieldUpdate_SchemaValue_Unset' - OptionalFieldUpdate_SchemaValue_Set: + - $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Set' + - $ref: '#/components/schemas/OptionalFieldUpdate_ExternalSchemaValue_Unset' + OptionalFieldUpdate_ExternalSchemaValue_Set: type: object properties: op: @@ -21231,11 +21721,11 @@ components: enum: - set value: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' required: - op - value - OptionalFieldUpdate_SchemaValue_Unset: + OptionalFieldUpdate_ExternalSchemaValue_Unset: type: object properties: op: diff --git a/openapi/golem-worker-service.yaml b/openapi/golem-worker-service.yaml index de848ad862..260233195c 100644 --- a/openapi/golem-worker-service.yaml +++ b/openapi/golem-worker-service.yaml @@ -2487,7 +2487,7 @@ components: agentTypeName: type: string parameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' phantomId: type: string format: uuid @@ -2499,7 +2499,7 @@ components: methodName: type: string methodParameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' mode: $ref: '#/components/schemas/AgentInvocationMode' scheduleAt: @@ -2524,7 +2524,7 @@ components: idempotencyKey: type: string result: - $ref: '#/components/schemas/TypedSchemaValue' + $ref: '#/components/schemas/ExternalTypedSchemaValue' componentRevision: type: integer format: uint64 @@ -3706,7 +3706,7 @@ components: agentTypeName: type: string parameters: - $ref: '#/components/schemas/SchemaValue' + $ref: '#/components/schemas/ExternalSchemaValue' phantomId: type: string format: uuid @@ -3895,6 +3895,496 @@ components: format: uint64 description: $ref: '#/components/schemas/AgentResourceDescription' + ExternalResultValuePayload: + type: object + oneOf: + - type: object + required: + - tag + properties: + tag: + type: string + enum: + - ok + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + - type: object + required: + - tag + properties: + tag: + type: string + enum: + - err + value: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + ExternalSchemaValue: + type: object + oneOf: + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - bool + value: + type: boolean + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s8 + value: + type: integer + format: int8 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s16 + value: + type: integer + format: int16 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s32 + value: + type: integer + format: int32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - s64 + value: + type: integer + format: int64 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u8 + value: + type: integer + format: uint8 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u16 + value: + type: integer + format: uint16 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u32 + value: + type: integer + format: uint32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - u64 + value: + type: integer + format: uint64 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - f32 + value: + type: number + format: float + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - f64 + value: + type: number + format: double + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - char + value: + type: char + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - string + value: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - record + value: + type: object + required: + - fields + properties: + fields: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - variant + value: + $ref: '#/components/schemas/ExternalVariantValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - enum + value: + type: object + required: + - case + properties: + case: + type: integer + format: uint32 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - flags + value: + type: object + required: + - bits + properties: + bits: + type: array + items: + type: boolean + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - tuple + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - list + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - fixed-list + value: + type: object + required: + - elements + properties: + elements: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - map + value: + type: object + required: + - entries + properties: + entries: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ExternalSchemaValue' + maxItems: 2 + minItems: 2 + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - option + value: + type: object + properties: + inner: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - result + value: + $ref: '#/components/schemas/ExternalResultValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - text + value: + $ref: '#/components/schemas/TextValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - binary + value: + $ref: '#/components/schemas/BinaryValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - path + value: + type: object + required: + - path + properties: + path: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - url + value: + type: object + required: + - url + properties: + url: + type: string + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - datetime + value: + type: object + required: + - value + properties: + value: + type: string + format: date-time + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - duration + value: + $ref: '#/components/schemas/DurationValuePayload' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - quantity + value: + $ref: '#/components/schemas/QuantityValue' + - type: object + required: + - kind + - value + properties: + kind: + type: string + enum: + - union + value: + $ref: '#/components/schemas/ExternalUnionValuePayload' + ExternalTypedSchemaValue: + type: object + required: + - graph + - value + properties: + graph: + $ref: '#/components/schemas/SchemaGraph' + value: + $ref: '#/components/schemas/ExternalSchemaValue' + ExternalUnionValuePayload: + type: object + required: + - tag + - body + properties: + tag: + type: string + body: + $ref: '#/components/schemas/ExternalSchemaValue' + ExternalVariantValuePayload: + type: object + required: + - case + properties: + case: + type: integer + format: uint32 + payload: + nullable: true + allOf: + - $ref: '#/components/schemas/ExternalSchemaValue' + - nullable: true FailedUpdate: type: object title: FailedUpdate diff --git a/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/CancellationToken.scala b/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/CancellationToken.scala index 82639ef46a..867d4046c9 100644 --- a/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/CancellationToken.scala +++ b/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/CancellationToken.scala @@ -31,6 +31,6 @@ object CancellationToken { private[rpc] def apply(raw: RawCancellationToken): CancellationToken = new CancellationToken(() => raw.cancel()) - private[rpc] def fromFunction(fn: () => Unit): CancellationToken = + private[golem] def fromFunction(fn: () => Unit): CancellationToken = new CancellationToken(fn) } diff --git a/sdks/ts/packages/golem-ts-sdk/src/bridge/index.ts b/sdks/ts/packages/golem-ts-sdk/src/bridge/index.ts index e40978ff61..84db7eb496 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/bridge/index.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/bridge/index.ts @@ -30,3 +30,4 @@ export type UnstructuredBinaryType = export * from './schema'; export * from './agent'; export * from './tool'; +export { withCapabilityAdoptionTransaction } from '../internal/schema-model/capabilityTransaction'; diff --git a/sdks/ts/packages/golem-ts-sdk/src/bridge/schema.ts b/sdks/ts/packages/golem-ts-sdk/src/bridge/schema.ts index 39c0676bda..858817e265 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/bridge/schema.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/bridge/schema.ts @@ -1,9 +1,79 @@ // Copyright 2024-2026 Golem Cloud // Licensed under the Golem Source License v1.1 -import type { Datetime } from 'golem:core/types@2.0.0'; -import { deepEqual, type SchemaGraph, type TypedSchemaValue } from '../internal/schema-model'; +import type { + Datetime, + PermissionCard as RawPermissionCard, + Secret as RawSecret, +} from 'golem:core/types@2.0.0'; +import { + deepEqual, + type SchemaGraph, + type SchemaValue, + type TypedSchemaValue, + v, +} from '../internal/schema-model'; +import { PERMISSION_CARD_INTERNAL } from '../internal/schema-model/permissionCardInternal'; +import { + adoptGuestPermissionCardHandle, + releaseGuestPermissionCardHandle, +} from '../internal/schema-model/permissionCardHandle'; +import { QUOTA_INTERNAL } from '../internal/schema-model/quotaInternal'; +import { SECRET_INTERNAL } from '../internal/schema-model/secretInternal'; +import { + adoptGuestSecretHandle, + releaseGuestSecretHandle, +} from '../internal/schema-model/secretHandle'; import { schemaValueConforms } from '../internal/tool/validation'; +import { + quotaTokenFromSchemaValueInternal, + quotaTokenToSchemaValueInternal, + QuotaToken, +} from '../host/quota'; + +export type SecretHandle = RawSecret; +export type PermissionCardHandle = RawPermissionCard; +export { QuotaToken }; + +export function secretHandleToSchemaValue(value: SecretHandle): SchemaValue { + return v.secret(adoptGuestSecretHandle(SECRET_INTERNAL, value)); +} + +export function secretHandleFromSchemaValue(value: SchemaValue): SecretHandle { + if (value.tag !== 'secret') { + throw new Error(`Expected a secret schema value, got '${value.tag}'`); + } + const raw = releaseGuestSecretHandle(SECRET_INTERNAL, value.handle); + if (raw === undefined) { + throw new Error('secret handle was already consumed; an owned secret can only be decoded once'); + } + return raw; +} + +export function quotaTokenToSchemaValue(value: QuotaToken): SchemaValue { + return quotaTokenToSchemaValueInternal(QUOTA_INTERNAL, value); +} + +export function quotaTokenFromSchemaValue(value: SchemaValue): QuotaToken { + return quotaTokenFromSchemaValueInternal(QUOTA_INTERNAL, value); +} + +export function permissionCardHandleToSchemaValue(value: PermissionCardHandle): SchemaValue { + return v.permissionCard(adoptGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value)); +} + +export function permissionCardHandleFromSchemaValue(value: SchemaValue): PermissionCardHandle { + if (value.tag !== 'permission-card') { + throw new Error(`Expected a permission-card schema value, got '${value.tag}'`); + } + const raw = releaseGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value.handle); + if (raw === undefined) { + throw new Error( + 'permission-card handle was already consumed; an owned permission-card can only be decoded once', + ); + } + return raw; +} export function typedSchemaValueConforms( expectedGraph: SchemaGraph, diff --git a/sdks/ts/packages/golem-ts-sdk/src/client.ts b/sdks/ts/packages/golem-ts-sdk/src/client.ts index dbd37540ec..a3ad7c1d2c 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/client.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/client.ts @@ -25,7 +25,7 @@ import type { InvocationMetadata, CancelableScheduledInvocationReceipt, } from 'golem:agent/host@2.0.0'; -import { v } from './internal/schema-model'; +import { encodeChild, v, withIsolatedCapabilityAdoptionTransaction } from './internal/schema-model'; import type { SchemaGraph, SchemaType, SchemaValue } from './internal/schema-model'; import { compileConfig, ConfigDeclaration } from './config'; import { Uuid } from './uuid'; @@ -159,7 +159,9 @@ interface CompiledRemoteMethod { /** Encode a method/constructor input record (positional, declaration order). */ function encodeRecord(codecs: NamedCodec[], input: Record) { - return v.record(codecs.map((c) => c.codec.toValue(input[c.name]))); + return withIsolatedCapabilityAdoptionTransaction(() => + v.record(codecs.map((c) => encodeChild(c.codec, input[c.name]))), + ); } function assertValueMatchesType(value: SchemaValue, type: SchemaType, graph: SchemaGraph): void { diff --git a/sdks/ts/packages/golem-ts-sdk/src/host/quota.ts b/sdks/ts/packages/golem-ts-sdk/src/host/quota.ts index d6f11f5f71..cb44861d91 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/host/quota.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/host/quota.ts @@ -20,13 +20,29 @@ import { merge as rawMerge, Reservation as RawReservation, } from 'golem:quota/types@1.5.0'; -import { GuestQuotaTokenHandle, type SchemaValue, v } from '../internal/schema-model'; +import { type SchemaValue, v } from '../internal/schema-model'; import { QUOTA_INTERNAL, type QuotaInternal } from '../internal/schema-model/quotaInternal'; +import { + createGuestQuotaTokenHandle, + GuestQuotaTokenHandle, + peekGuestQuotaTokenHandle, + takeGuestQuotaTokenHandle, +} from '../internal/schema-model/quotaTokenHandle'; import { isPromiseLike } from './guard'; import { Result } from './result'; export type { FailedReservation }; +const tokenHandles = new WeakMap(); + +function handleOf(token: QuotaToken): GuestQuotaTokenHandle { + const handle = tokenHandles.get(token); + if (handle === undefined) { + throw new Error('invalid quota token'); + } + return handle; +} + /** * A committed or in-flight resource-consumption handle. * @@ -66,16 +82,12 @@ export class Reservation { * Or use the RAII helper {@link withQuotaToken} to commit automatically. */ export class QuotaToken { - // True ECMAScript private field: the opaque owned handle is unreachable from - // guest code, so a `QuotaToken` cannot be forged or have its capability - // extracted by reaching into the instance. - readonly #handle: GuestQuotaTokenHandle; - - private constructor(handle: GuestQuotaTokenHandle) { + constructor(key: QuotaInternal, handle: GuestQuotaTokenHandle) { + requireQuotaInternal(key); if (!(handle instanceof GuestQuotaTokenHandle)) { throw new Error('QuotaToken can only be constructed from an opaque quota-token handle'); } - this.#handle = handle; + tokenHandles.set(this, handle); } /** @@ -91,17 +103,15 @@ export class QuotaToken { * token first if you need to both keep and send a capability. */ reserve(amount: bigint): Result { - const result = this.#handle.withHandle((raw): Result => { - try { - return Result.ok(new Reservation(rawReserve(raw, amount))); - } catch (e) { - return Result.err(e as FailedReservation); - } - }); - if (result === undefined) { + const raw = peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handleOf(this)); + if (raw === undefined) { throw new Error(TOKEN_CONSUMED); } - return result; + try { + return Result.ok(new Reservation(rawReserve(raw, amount))); + } catch (e) { + return Result.err(e as FailedReservation); + } } /** @@ -115,11 +125,12 @@ export class QuotaToken { * if this token has already been transferred. */ split(childExpectedUse: bigint): QuotaToken { - const raw = this.#handle.withHandle((h) => rawSplit(h, childExpectedUse)); - if (raw === undefined) { + const parent = peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handleOf(this)); + if (parent === undefined) { throw new Error(TOKEN_CONSUMED); } - return new QuotaToken(GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, raw)); + const child = rawSplit(parent, childExpectedUse); + return new QuotaToken(QUOTA_INTERNAL, createGuestQuotaTokenHandle(QUOTA_INTERNAL, child)); } /** @@ -132,57 +143,23 @@ export class QuotaToken { * already been transferred. */ merge(other: QuotaToken): void { + const thisHandle = handleOf(this); + const otherHandle = handleOf(other); // Reject merging a token into itself before taking any handle, so a shared // handle is not consumed by the receiver and then read again as `other`. - if (other.#handle === this.#handle) { + if (otherHandle === thisHandle) { throw new Error('cannot merge a quota token with itself'); } // Check this token first so a consumed receiver does not consume `other`. - if (!this.#handle.isPresent()) { + const thisRaw = peekGuestQuotaTokenHandle(QUOTA_INTERNAL, thisHandle); + if (thisRaw === undefined) { throw new Error(TOKEN_CONSUMED); } - const otherRaw = other.#handle.take(); + const otherRaw = takeGuestQuotaTokenHandle(QUOTA_INTERNAL, otherHandle); if (otherRaw === undefined) { throw new Error(TOKEN_CONSUMED); } - this.#handle.withHandle((h) => rawMerge(h, otherRaw)); - } - - /** - * Lower the token into a schema value by sharing its opaque owned handle. The - * handle is not transferred here; it is moved out of the cell only when the - * resulting `SchemaValue` is encoded into a WIT `schema-value-tree`. - * - * This exposes the opaque handle, so it is gated behind the unexported - * {@link QUOTA_INTERNAL} key: only SDK-internal code (the value mapping layer) - * may extract a token's handle. A guest cannot, so it cannot reach the raw - * owned resource to forge or duplicate the capability. - */ - _toSchemaValue(key: QuotaInternal): SchemaValue { - requireQuotaInternal(key); - return v.quotaToken(this.#handle); - } - - /** - * Reconstruct a token from a decoded schema value's opaque handle. Gated - * behind {@link QUOTA_INTERNAL} so only SDK-internal code can wrap a handle - * back into a token. - */ - static _fromSchemaValue(key: QuotaInternal, value: SchemaValue): QuotaToken { - requireQuotaInternal(key); - if (value.tag !== 'quota-token') { - throw new Error(`Expected a quota-token schema value, got '${value.tag}'`); - } - return new QuotaToken(value.handle); - } - - /** - * Wrap a freshly acquired owned handle. Gated behind {@link QUOTA_INTERNAL} so - * only SDK-internal code can construct a token from a raw handle. - */ - static _fromHandle(key: QuotaInternal, handle: GuestQuotaTokenHandle): QuotaToken { - requireQuotaInternal(key); - return new QuotaToken(handle); + rawMerge(thisRaw, otherRaw); } /** @@ -197,6 +174,25 @@ export class QuotaToken { } } +export function quotaTokenToSchemaValueInternal( + key: QuotaInternal, + value: QuotaToken, +): SchemaValue { + requireQuotaInternal(key); + return v.quotaToken(handleOf(value)); +} + +export function quotaTokenFromSchemaValueInternal( + key: QuotaInternal, + value: SchemaValue, +): QuotaToken { + requireQuotaInternal(key); + if (value.tag !== 'quota-token') { + throw new Error(`Expected a quota-token schema value, got '${value.tag}'`); + } + return new QuotaToken(key, value.handle); +} + function requireQuotaInternal(key: QuotaInternal): void { if (key !== QUOTA_INTERNAL) { throw new Error('this is an internal SDK operation on a quota token'); @@ -215,9 +211,9 @@ const TOKEN_CONSUMED = * credit rate and max-credit for fair scheduling. */ export function acquireQuotaToken(resourceName: string, expectedUse: bigint): QuotaToken { - return QuotaToken._fromHandle( + return new QuotaToken( QUOTA_INTERNAL, - GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, newToken(resourceName, expectedUse)), + createGuestQuotaTokenHandle(QUOTA_INTERNAL, newToken(resourceName, expectedUse)), ); } diff --git a/sdks/ts/packages/golem-ts-sdk/src/index.ts b/sdks/ts/packages/golem-ts-sdk/src/index.ts index 0e6d1c9a60..ab7d9c6dce 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/index.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/index.ts @@ -64,7 +64,8 @@ export * from './webhook'; export * from './host/hostapi'; export * as oplog from './host/oplog'; export * from './host/guard'; -export * from './host/quota'; +export { acquireQuotaToken, QuotaToken, Reservation, withReservation } from './host/quota'; +export type { FailedReservation } from './host/quota'; export * from './host/retry'; export * from './host/result'; export * from './host/saga'; diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/capabilityTransaction.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/capabilityTransaction.ts new file mode 100644 index 0000000000..b8ef097869 --- /dev/null +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/capabilityTransaction.ts @@ -0,0 +1,85 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +type Rollback = () => void; + +let activeRollbacks: Rollback[] | undefined; + +/** + * Atomically build a schema value that may adopt owned capability handles. + * Nested conversions share the outer journal, while a failed nested conversion + * rolls back only the handles it adopted. + */ +export function withCapabilityAdoptionTransaction(encode: () => T): T { + const journal = activeRollbacks ?? []; + const start = journal.length; + const outermost = activeRollbacks === undefined; + if (outermost) activeRollbacks = journal; + + try { + return encode(); + } catch (error) { + for (let i = journal.length - 1; i >= start; i--) { + try { + journal[i]!(); + } catch { + // Preserve the conversion failure while still attempting every rollback. + } + } + journal.length = start; + throw error; + } finally { + if (outermost) activeRollbacks = undefined; + } +} + +/** + * Run a public root conversion in a journal isolated from any conversion that + * synchronously invoked it. Successful adoptions belong only to this call. + */ +export function withIsolatedCapabilityAdoptionTransaction(encode: () => T): T { + const previous = activeRollbacks; + activeRollbacks = []; + try { + return withCapabilityAdoptionTransaction(encode); + } finally { + activeRollbacks = previous; + } +} + +type Encoder = (value: unknown) => unknown; + +const rootChildren = new WeakMap(); + +/** Make an encoder an isolated public root while retaining its joining child encoder. */ +export function isolateCapabilityRoot(encode: (value: unknown) => T): (value: unknown) => T { + const root = (value: unknown): T => + withIsolatedCapabilityAdoptionTransaction(() => encode(value)); + rootChildren.set(root, encode); + return root; +} + +/** Encode one structural child without re-entering that child's public root boundary. */ +export function encodeChild( + codec: { readonly toValue: (value: unknown) => T }, + value: unknown, +): T { + const child = rootChildren.get(codec.toValue) as ((value: unknown) => T) | undefined; + return (child ?? codec.toValue)(value); +} + +/** Register a newly adopted handle with the active synchronous conversion. */ +export function registerCapabilityAdoption(rollback: Rollback): void { + activeRollbacks?.push(rollback); +} diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/index.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/index.ts index 27f7d9086d..d63e729892 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/index.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/index.ts @@ -21,6 +21,7 @@ export * from './secretHandle'; export * from './quotaTokenHandle'; export * from './schemaValueStreamHandle'; export * from './permissionCardHandle'; +export * from './capabilityTransaction'; export * from './model'; export * from './builder'; export * from './wit'; diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/model.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/model.ts index 8894200857..cb5b57c347 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/model.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/model.ts @@ -649,7 +649,12 @@ export function cloneSchemaValue(value: SchemaValue): SchemaValue { * value is `undefined` are ignored, matching the WIT option lifting convention * (and Vitest's `toEqual`). */ -export function deepEqual(a: unknown, b: unknown): boolean { +export function deepEqual( + a: unknown, + b: unknown, + equivalent?: (a: unknown, b: unknown) => boolean, +): boolean { + if (equivalent?.(a, b)) return true; // Numbers use `Object.is` so that `NaN` equals `NaN` and, crucially, `-0` does // NOT equal `0` (a real f32/f64 round-trip difference we must not mask). if (typeof a === 'number' && typeof b === 'number') { @@ -685,6 +690,17 @@ export function deepEqual(a: unknown, b: unknown): boolean { if (a instanceof Map || b instanceof Map) { if (!(a instanceof Map) || !(b instanceof Map)) return false; if (a.size !== b.size) return false; + if (equivalent !== undefined) { + const unmatched = Array.from(b.entries()); + for (const [ak, av] of a) { + const index = unmatched.findIndex(([bk]) => deepEqual(ak, bk, equivalent)); + if (index < 0) return false; + const [, bv] = unmatched[index]!; + if (!deepEqual(av, bv, equivalent)) return false; + unmatched.splice(index, 1); + } + return true; + } for (const [k, av] of a) { if (!b.has(k)) return false; if (!deepEqual(av, b.get(k))) return false; @@ -695,7 +711,9 @@ export function deepEqual(a: unknown, b: unknown): boolean { if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i], equivalent)) return false; + } return true; } @@ -705,7 +723,7 @@ export function deepEqual(a: unknown, b: unknown): boolean { for (const k of Object.keys(ao)) if (ao[k] !== undefined) keys.add(k); for (const k of Object.keys(bo)) if (bo[k] !== undefined) keys.add(k); for (const k of keys) { - if (!deepEqual(ao[k], bo[k])) return false; + if (!deepEqual(ao[k], bo[k], equivalent)) return false; } return true; } diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/permissionCardHandle.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/permissionCardHandle.ts index fcf8db2287..bd17096fc3 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/permissionCardHandle.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/permissionCardHandle.ts @@ -13,43 +13,163 @@ // limitations under the License. import type { PermissionCard as RawPermissionCard } from 'golem:core/types@2.0.0'; +import { registerCapabilityAdoption } from './capabilityTransaction'; import { PERMISSION_CARD_INTERNAL, type PermissionCardInternal } from './permissionCardInternal'; +interface PermissionCardHandleState { + raw: RawPermissionCard | undefined; + readonly tracked: boolean; +} + +const states = new WeakMap(); +const owners = new WeakMap(); +const transferredOwner = Object.freeze({}); + /** Guest-side take-once carrier for an owned, opaque permission-card resource. */ export class GuestPermissionCardHandle { - #raw: RawPermissionCard | undefined; + constructor(key: PermissionCardInternal, raw: RawPermissionCard, tracked = true) { + if (key !== PERMISSION_CARD_INTERNAL) { + throw new Error('GuestPermissionCardHandle construction is an internal SDK operation'); + } + states.set(this, { raw, tracked }); + } - private constructor(raw: RawPermissionCard) { - this.#raw = raw; + toJSON(): never { + throw new Error( + 'permission-card handles cannot be serialized; transfer them through a WIT schema-value-tree', + ); } +} - static fromRaw(key: PermissionCardInternal, raw: RawPermissionCard): GuestPermissionCardHandle { - if (key !== PERMISSION_CARD_INTERNAL) { - throw new Error('GuestPermissionCardHandle.fromRaw is an internal SDK operation'); - } - return new GuestPermissionCardHandle(raw); +function requirePermissionCardInternal(key: PermissionCardInternal): void { + if (key !== PERMISSION_CARD_INTERNAL) { + throw new Error('this is an internal SDK operation on a permission-card handle'); + } +} + +function stateOf(handle: GuestPermissionCardHandle): PermissionCardHandleState { + const state = states.get(handle); + if (state === undefined) { + throw new Error('invalid permission-card handle'); + } + return state; +} + +export function createGuestPermissionCardHandle( + key: PermissionCardInternal, + raw: RawPermissionCard, +): GuestPermissionCardHandle { + requirePermissionCardInternal(key); + if (owners.has(raw)) { + throw new Error('permission-card handle is already owned'); } + const handle = new GuestPermissionCardHandle(key, raw); + owners.set(raw, handle); + return handle; +} + +export function createUntrackedGuestPermissionCardHandle( + key: PermissionCardInternal, + raw: RawPermissionCard, +): GuestPermissionCardHandle { + requirePermissionCardInternal(key); + return new GuestPermissionCardHandle(key, raw, false); +} + +export function adoptGuestPermissionCardHandle( + key: PermissionCardInternal, + raw: RawPermissionCard, +): GuestPermissionCardHandle { + const handle = createGuestPermissionCardHandle(key, raw); + registerCapabilityAdoption(() => releaseGuestPermissionCardHandle(key, handle)); + return handle; +} - /** Whether the card is still present and has not been transferred. */ - isPresent(): boolean { - return this.#raw !== undefined; +export function peekGuestPermissionCardHandle( + key: PermissionCardInternal, + handle: GuestPermissionCardHandle, +): RawPermissionCard | undefined { + requirePermissionCardInternal(key); + return stateOf(handle).raw; +} + +export function takeGuestPermissionCardHandle( + key: PermissionCardInternal, + handle: GuestPermissionCardHandle, +): RawPermissionCard | undefined { + requirePermissionCardInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('permission-card handle ownership is invalid'); } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.set(raw, transferredOwner); + return raw; +} - /** Move the owned card out of this handle at most once. */ - take(): RawPermissionCard | undefined { - const raw = this.#raw; - this.#raw = undefined; - return raw; +export function takeGuestPermissionCardHandleToWire( + key: PermissionCardInternal, + handle: GuestPermissionCardHandle, + wireOwner: object, +): RawPermissionCard | undefined { + requirePermissionCardInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('permission-card handle ownership is invalid'); } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.set(raw, wireOwner); + return raw; +} - /** Inspect the resource identity without transferring ownership. */ - withHandle(f: (raw: RawPermissionCard) => R): R | undefined { - return this.#raw === undefined ? undefined : f(this.#raw); +export function assertGuestPermissionCardHandleCanLiftFromWire( + key: PermissionCardInternal, + raw: RawPermissionCard, + wireOwner: object, +): void { + requirePermissionCardInternal(key); + const owner = owners.get(raw); + if (owner !== undefined && owner !== wireOwner) { + throw new Error('permission-card handle is already owned'); } +} - toJSON(): never { - throw new Error( - 'permission-card handles cannot be serialized; transfer them through a WIT schema-value-tree', - ); +export function liftGuestPermissionCardHandleFromWire( + key: PermissionCardInternal, + raw: RawPermissionCard, + wireOwner: object, +): GuestPermissionCardHandle { + assertGuestPermissionCardHandleCanLiftFromWire(key, raw, wireOwner); + const handle = new GuestPermissionCardHandle(key, raw); + owners.set(raw, handle); + return handle; +} + +export function abandonGuestPermissionCardWireHandle( + key: PermissionCardInternal, + raw: RawPermissionCard, + wireOwner: object, +): void { + requirePermissionCardInternal(key); + const owner = owners.get(raw); + if (owner === undefined || owner === wireOwner) { + owners.set(raw, transferredOwner); + } +} + +export function releaseGuestPermissionCardHandle( + key: PermissionCardInternal, + handle: GuestPermissionCardHandle, +): RawPermissionCard | undefined { + requirePermissionCardInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('permission-card handle ownership is invalid'); } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.delete(raw); + return raw; } diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaInternal.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaInternal.ts index 7779e5abd1..7183e4d198 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaInternal.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaInternal.ts @@ -18,10 +18,8 @@ // A `quota-token` is an unforgeable, affine capability: a guest may hold and // transfer it but must never extract the raw owned handle, re-wrap it, or // duplicate it — doing so would let it forge or double-spend the capability. -// The privileged operations (`GuestQuotaTokenHandle.fromRaw`, -// `QuotaToken._toSchemaValue` / `_fromSchemaValue` / `_fromHandle`) all require -// this key as a witness, so only SDK-internal modules that can import it may -// call them. +// The privileged holder and bridge conversion functions all require this key +// as a witness, so only SDK-internal modules that can import it may call them. // // This module is intentionally NOT re-exported from the package's public entry // point (`src/index.ts`) nor from the `internal/schema-model` barrel, so guest diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaTokenHandle.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaTokenHandle.ts index c7d9b47851..d7a4234e5c 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaTokenHandle.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/quotaTokenHandle.ts @@ -25,59 +25,24 @@ // is an alias — both are rejected by the encoder's preflight pass. import type { QuotaToken as RawQuotaToken } from 'golem:core/types@2.0.0'; +import { registerCapabilityAdoption } from './capabilityTransaction'; import { QUOTA_INTERNAL, type QuotaInternal } from './quotaInternal'; -export class GuestQuotaTokenHandle { - // A true ECMAScript private field, not a TypeScript-only `private`: the owned - // resource is unreachable from guest code even through `as any` / field - // access, so the handle cannot be inspected, copied, or re-wrapped. - #raw: RawQuotaToken | undefined; +interface QuotaTokenHandleState { + raw: RawQuotaToken | undefined; + readonly tracked: boolean; +} - private constructor(raw: RawQuotaToken) { - this.#raw = raw; - } +const states = new WeakMap(); +const owners = new WeakMap(); +const transferredOwner = Object.freeze({}); - /** - * Wrap a freshly received owned handle in a take-once cell. - * - * Wrapping a raw owned resource is a privileged operation: it is the - * primitive a guest would use to forge or duplicate a capability. It requires - * the unexported {@link QUOTA_INTERNAL} key so only SDK-internal code can call - * it. (`GuestQuotaTokenHandle` is itself not part of the package's public API, - * so this is defense in depth.) - */ - static fromRaw(key: QuotaInternal, raw: RawQuotaToken): GuestQuotaTokenHandle { +export class GuestQuotaTokenHandle { + constructor(key: QuotaInternal, raw: RawQuotaToken, tracked = true) { if (key !== QUOTA_INTERNAL) { - throw new Error('GuestQuotaTokenHandle.fromRaw is an internal SDK operation'); + throw new Error('GuestQuotaTokenHandle construction is an internal SDK operation'); } - return new GuestQuotaTokenHandle(raw); - } - - /** Whether the handle is still present (not yet transferred). */ - isPresent(): boolean { - return this.#raw !== undefined; - } - - /** - * Take the owned handle out of the cell. Returns `undefined` if it was - * already transferred (consumed) by a previous encode. - */ - take(): RawQuotaToken | undefined { - const raw = this.#raw; - this.#raw = undefined; - return raw; - } - - /** - * Run `f` with the owned handle, if it is still present (i.e. has not been - * transferred out by an encode). Returns `undefined` if the handle was - * already consumed. - * - * Used by the SDK wrappers to invoke borrowing quota operations (`reserve`, - * `split`) on the underlying resource without taking ownership of it. - */ - withHandle(f: (raw: RawQuotaToken) => R): R | undefined { - return this.#raw === undefined ? undefined : f(this.#raw); + states.set(this, { raw, tracked }); } /** @@ -91,3 +56,136 @@ export class GuestQuotaTokenHandle { ); } } + +function requireQuotaInternal(key: QuotaInternal): void { + if (key !== QUOTA_INTERNAL) { + throw new Error('this is an internal SDK operation on a quota-token handle'); + } +} + +function stateOf(handle: GuestQuotaTokenHandle): QuotaTokenHandleState { + const state = states.get(handle); + if (state === undefined) { + throw new Error('invalid quota-token handle'); + } + return state; +} + +export function createGuestQuotaTokenHandle( + key: QuotaInternal, + raw: RawQuotaToken, +): GuestQuotaTokenHandle { + requireQuotaInternal(key); + if (owners.has(raw)) { + throw new Error('quota-token handle is already owned'); + } + const handle = new GuestQuotaTokenHandle(key, raw); + owners.set(raw, handle); + return handle; +} + +export function createUntrackedGuestQuotaTokenHandle( + key: QuotaInternal, + raw: RawQuotaToken, +): GuestQuotaTokenHandle { + requireQuotaInternal(key); + return new GuestQuotaTokenHandle(key, raw, false); +} + +export function adoptGuestQuotaTokenHandle( + key: QuotaInternal, + raw: RawQuotaToken, +): GuestQuotaTokenHandle { + const handle = createGuestQuotaTokenHandle(key, raw); + registerCapabilityAdoption(() => releaseGuestQuotaTokenHandle(key, handle)); + return handle; +} + +export function peekGuestQuotaTokenHandle( + key: QuotaInternal, + handle: GuestQuotaTokenHandle, +): RawQuotaToken | undefined { + requireQuotaInternal(key); + return stateOf(handle).raw; +} + +export function takeGuestQuotaTokenHandle( + key: QuotaInternal, + handle: GuestQuotaTokenHandle, +): RawQuotaToken | undefined { + requireQuotaInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('quota-token handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.set(raw, transferredOwner); + return raw; +} + +export function takeGuestQuotaTokenHandleToWire( + key: QuotaInternal, + handle: GuestQuotaTokenHandle, + wireOwner: object, +): RawQuotaToken | undefined { + requireQuotaInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('quota-token handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.set(raw, wireOwner); + return raw; +} + +export function assertGuestQuotaTokenHandleCanLiftFromWire( + key: QuotaInternal, + raw: RawQuotaToken, + wireOwner: object, +): void { + requireQuotaInternal(key); + const owner = owners.get(raw); + if (owner !== undefined && owner !== wireOwner) { + throw new Error('quota-token handle is already owned'); + } +} + +export function liftGuestQuotaTokenHandleFromWire( + key: QuotaInternal, + raw: RawQuotaToken, + wireOwner: object, +): GuestQuotaTokenHandle { + assertGuestQuotaTokenHandleCanLiftFromWire(key, raw, wireOwner); + const handle = new GuestQuotaTokenHandle(key, raw); + owners.set(raw, handle); + return handle; +} + +export function abandonGuestQuotaTokenWireHandle( + key: QuotaInternal, + raw: RawQuotaToken, + wireOwner: object, +): void { + requireQuotaInternal(key); + const owner = owners.get(raw); + if (owner === undefined || owner === wireOwner) { + owners.set(raw, transferredOwner); + } +} + +export function releaseGuestQuotaTokenHandle( + key: QuotaInternal, + handle: GuestQuotaTokenHandle, +): RawQuotaToken | undefined { + requireQuotaInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('quota-token handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined && state.tracked) owners.delete(raw); + return raw; +} diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/secretHandle.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/secretHandle.ts index 964b21fbe0..b31fdc6785 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/secretHandle.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/secretHandle.ts @@ -13,55 +13,158 @@ // limitations under the License. import type { Secret as RawSecret } from 'golem:core/types@2.0.0'; +import { registerCapabilityAdoption } from './capabilityTransaction'; import { SECRET_INTERNAL, type SecretInternal } from './secretInternal'; -export class GuestSecretHandle { - #raw: RawSecret | undefined; - readonly #onTake?: () => void; +interface SecretHandleState { + raw: RawSecret | undefined; + readonly onTake?: () => void; + readonly tracked: boolean; +} - private constructor(raw: RawSecret, onTake?: () => void) { - this.#raw = raw; - this.#onTake = onTake; - } +const states = new WeakMap(); +const owners = new WeakMap(); +const transferredOwner = Object.freeze({}); - static fromRaw(key: SecretInternal, raw: RawSecret): GuestSecretHandle { +export class GuestSecretHandle { + constructor(key: SecretInternal, raw: RawSecret, onTake?: () => void, tracked = true) { if (key !== SECRET_INTERNAL) { - throw new Error('GuestSecretHandle.fromRaw is an internal SDK operation'); + throw new Error('GuestSecretHandle construction is an internal SDK operation'); } - return new GuestSecretHandle(raw); + states.set(this, { raw, onTake, tracked }); } - static fromRawWithTakeCallback( - key: SecretInternal, - raw: RawSecret, - onTake: () => void, - ): GuestSecretHandle { - if (key !== SECRET_INTERNAL) { - throw new Error('GuestSecretHandle.fromRawWithTakeCallback is an internal SDK operation'); - } - return new GuestSecretHandle(raw, onTake); + toJSON(): never { + throw new Error( + 'secret handles cannot be serialized; transfer them through a WIT schema-value-tree', + ); } +} - isPresent(): boolean { - return this.#raw !== undefined; +function requireSecretInternal(key: SecretInternal): void { + if (key !== SECRET_INTERNAL) { + throw new Error('this is an internal SDK operation on a secret handle'); } +} - take(): RawSecret | undefined { - const raw = this.#raw; - this.#raw = undefined; - if (raw !== undefined) { - this.#onTake?.(); - } - return raw; +function stateOf(handle: GuestSecretHandle): SecretHandleState { + const state = states.get(handle); + if (state === undefined) { + throw new Error('invalid secret handle'); } + return state; +} - withHandle(f: (raw: RawSecret) => R): R | undefined { - return this.#raw === undefined ? undefined : f(this.#raw); +export function createGuestSecretHandle( + key: SecretInternal, + raw: RawSecret, + onTake?: () => void, +): GuestSecretHandle { + requireSecretInternal(key); + if (owners.has(raw)) { + throw new Error('secret handle is already owned'); } + const handle = new GuestSecretHandle(key, raw, onTake); + owners.set(raw, handle); + return handle; +} - toJSON(): never { - throw new Error( - 'secret handles cannot be serialized; transfer them through a WIT schema-value-tree', - ); +export function createUntrackedGuestSecretHandle( + key: SecretInternal, + raw: RawSecret, +): GuestSecretHandle { + requireSecretInternal(key); + return new GuestSecretHandle(key, raw, undefined, false); +} + +export function adoptGuestSecretHandle(key: SecretInternal, raw: RawSecret): GuestSecretHandle { + const handle = createGuestSecretHandle(key, raw); + registerCapabilityAdoption(() => releaseGuestSecretHandle(key, handle)); + return handle; +} + +export function peekGuestSecretHandle( + key: SecretInternal, + handle: GuestSecretHandle, +): RawSecret | undefined { + requireSecretInternal(key); + return stateOf(handle).raw; +} + +export function takeGuestSecretHandle( + key: SecretInternal, + handle: GuestSecretHandle, +): RawSecret | undefined { + requireSecretInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('secret handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined) { + if (state.tracked) owners.set(raw, transferredOwner); + state.onTake?.(); + } + return raw; +} + +export function takeGuestSecretHandleToWire( + key: SecretInternal, + handle: GuestSecretHandle, + wireOwner: object, +): RawSecret | undefined { + requireSecretInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('secret handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined) { + if (state.tracked) owners.set(raw, wireOwner); + state.onTake?.(); + } + return raw; +} + +export function assertGuestSecretHandleCanLiftFromWire( + key: SecretInternal, + raw: RawSecret, + wireOwner: object, +): void { + requireSecretInternal(key); + const owner = owners.get(raw); + if (owner !== undefined && owner !== wireOwner) { + throw new Error('secret handle is already owned'); + } +} + +export function liftGuestSecretHandleFromWire( + key: SecretInternal, + raw: RawSecret, + wireOwner: object, +): GuestSecretHandle { + assertGuestSecretHandleCanLiftFromWire(key, raw, wireOwner); + const handle = new GuestSecretHandle(key, raw); + owners.set(raw, handle); + return handle; +} + +export function releaseGuestSecretHandle( + key: SecretInternal, + handle: GuestSecretHandle, +): RawSecret | undefined { + requireSecretInternal(key); + const state = stateOf(handle); + const raw = state.raw; + if (raw !== undefined && state.tracked && owners.get(raw) !== handle) { + throw new Error('secret handle ownership is invalid'); + } + state.raw = undefined; + if (raw !== undefined) { + if (state.tracked) owners.delete(raw); + state.onTake?.(); } + return raw; } diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/wit.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/wit.ts index cd57b7725b..af54464aa7 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/wit.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/schema-model/wit.ts @@ -47,12 +47,35 @@ import { type NumericBound, emptyMetadata, } from './model'; -import { GuestSecretHandle } from './secretHandle'; +import { + assertGuestSecretHandleCanLiftFromWire, + GuestSecretHandle, + liftGuestSecretHandleFromWire, + peekGuestSecretHandle, + releaseGuestSecretHandle, + takeGuestSecretHandleToWire, +} from './secretHandle'; import { SECRET_INTERNAL } from './secretInternal'; -import { GuestQuotaTokenHandle } from './quotaTokenHandle'; +import { + abandonGuestQuotaTokenWireHandle, + assertGuestQuotaTokenHandleCanLiftFromWire, + GuestQuotaTokenHandle, + liftGuestQuotaTokenHandleFromWire, + peekGuestQuotaTokenHandle, + takeGuestQuotaTokenHandle, + takeGuestQuotaTokenHandleToWire, +} from './quotaTokenHandle'; import { QUOTA_INTERNAL } from './quotaInternal'; import { GuestSchemaValueStreamHandle } from './schemaValueStreamHandle'; -import { GuestPermissionCardHandle } from './permissionCardHandle'; +import { + abandonGuestPermissionCardWireHandle, + assertGuestPermissionCardHandleCanLiftFromWire, + GuestPermissionCardHandle, + liftGuestPermissionCardHandleFromWire, + peekGuestPermissionCardHandle, + takeGuestPermissionCardHandle, + takeGuestPermissionCardHandleToWire, +} from './permissionCardHandle'; import { PERMISSION_CARD_INTERNAL } from './permissionCardInternal'; import { SchemaDecodeError, SchemaEncodeError } from './errors'; @@ -598,7 +621,7 @@ export function assertSchemaValueRepresentable( if (!(v.handle instanceof GuestSecretHandle)) { throw new SchemaEncodeError('secret value contains an invalid secret handle'); } - const raw = v.handle.withHandle((r) => r); + const raw = peekGuestSecretHandle(SECRET_INTERNAL, v.handle); if (raw === undefined) { throw new SchemaEncodeError( 'secret handle was already transferred; an owned secret can only be sent once', @@ -619,7 +642,7 @@ export function assertSchemaValueRepresentable( // Peek the underlying owned resource without consuming it, so two // distinct holders wrapping the same raw resource are also rejected, not // only the same holder used twice. - const raw = v.handle.withHandle((r) => r); + const raw = peekGuestQuotaTokenHandle(QUOTA_INTERNAL, v.handle); if (raw === undefined) { throw new SchemaEncodeError( 'quota-token handle was already transferred; an owned quota-token can only be sent once', @@ -656,7 +679,7 @@ export function assertSchemaValueRepresentable( 'permission-card value contains an invalid permission-card handle', ); } - const raw = v.handle.withHandle((r) => r); + const raw = peekGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, v.handle); if (raw === undefined) { throw new SchemaEncodeError( 'permission-card handle was already transferred; an owned permission-card can only be sent once', @@ -849,25 +872,35 @@ export function schemaValueToWit(value: SchemaValue): WitSchemaValueTree { case 'union': return { tag: 'union-value', val: { tag: v.unionTag, body: emit(v.body) } }; case 'secret': { - const raw = v.handle.take(); + const node = { tag: 'secret-value', val: undefined } as unknown as Extract< + WitSchemaValueNode, + { tag: 'secret-value' } + >; + const raw = takeGuestSecretHandleToWire(SECRET_INTERNAL, v.handle, node); if (raw === undefined) { throw new SchemaEncodeError( 'secret handle was already transferred; an owned secret can only be sent once', ); } - return { tag: 'secret-value', val: raw }; + node.val = raw; + return node; } case 'quota-token': { // Move the owned `own` resource out of the take-once cell. // The preflight above guarantees the handle is present and unique, so // this take always succeeds; the guard is defensive only. - const raw = v.handle.take(); + const node = { tag: 'quota-token-handle', val: undefined } as unknown as Extract< + WitSchemaValueNode, + { tag: 'quota-token-handle' } + >; + const raw = takeGuestQuotaTokenHandleToWire(QUOTA_INTERNAL, v.handle, node); if (raw === undefined) { throw new SchemaEncodeError( 'quota-token handle was already transferred; an owned quota-token can only be sent once', ); } - return { tag: 'quota-token-handle', val: raw }; + node.val = raw; + return node; } case 'stream': { const stream = v.handle.take(); @@ -880,13 +913,18 @@ export function schemaValueToWit(value: SchemaValue): WitSchemaValueTree { return { tag: 'stream-value', val: stream.value }; } case 'permission-card': { - const raw = v.handle.take(); + const node = { tag: 'permission-card-handle', val: undefined } as unknown as Extract< + WitSchemaValueNode, + { tag: 'permission-card-handle' } + >; + const raw = takeGuestPermissionCardHandleToWire(PERMISSION_CARD_INTERNAL, v.handle, node); if (raw === undefined) { throw new SchemaEncodeError( 'permission-card handle was already transferred; an owned permission-card can only be sent once', ); } - return { tag: 'permission-card-handle', val: raw }; + node.val = raw; + return node; } default: throw new SchemaEncodeError(`unknown schema value tag '${(v as { tag: string }).tag}'`); @@ -1175,6 +1213,11 @@ export function preflightWitValueTree(nodes: WitSchemaValueNode[], root: ValueNo if (seenRaw.has(raw)) { throw new SchemaDecodeError('the same secret resource appeared more than once'); } + try { + assertGuestSecretHandleCanLiftFromWire(SECRET_INTERNAL, raw, n); + } catch (error) { + throw new SchemaDecodeError(error instanceof Error ? error.message : String(error)); + } seenRaw.add(raw); secretReached.add(idx); return; @@ -1240,6 +1283,11 @@ export function preflightWitValueTree(nodes: WitSchemaValueNode[], root: ValueNo if (seenRaw.has(raw)) { throw new SchemaDecodeError('the same quota-token resource appeared more than once'); } + try { + assertGuestQuotaTokenHandleCanLiftFromWire(QUOTA_INTERNAL, raw, n); + } catch (error) { + throw new SchemaDecodeError(error instanceof Error ? error.message : String(error)); + } seenRaw.add(raw); ownedHandleReached.add(idx); return; @@ -1255,6 +1303,11 @@ export function preflightWitValueTree(nodes: WitSchemaValueNode[], root: ValueNo if (seenRaw.has(raw)) { throw new SchemaDecodeError('the same permission-card resource appeared more than once'); } + try { + assertGuestPermissionCardHandleCanLiftFromWire(PERMISSION_CARD_INTERNAL, raw, n); + } catch (error) { + throw new SchemaDecodeError(error instanceof Error ? error.message : String(error)); + } seenRaw.add(raw); ownedHandleReached.add(idx); return; @@ -1338,7 +1391,11 @@ export function schemaValueFromWit(wit: WitSchemaValueTree): SchemaValue { // thrown error the whole decode is aborted and this local array is discarded, // so leaving stale `1`s during unwinding is harmless. const onPath = new Uint8Array(nodes.length); - const liftedSecrets: { node: { val: unknown }; raw: unknown }[] = []; + const liftedCapabilities: ( + | { tag: 'secret'; node: { val: unknown }; handle: GuestSecretHandle } + | { tag: 'quota-token'; handle: GuestQuotaTokenHandle } + | { tag: 'permission-card'; handle: GuestPermissionCardHandle } + )[] = []; function fromIdx(idx: ValueNodeIndex): SchemaValue { if (idx < 0 || idx >= nodes.length) { @@ -1457,8 +1514,15 @@ export function schemaValueFromWit(wit: WitSchemaValueTree): SchemaValue { throw new SchemaDecodeError('secret handle referenced more than once'); } (n as { val: unknown }).val = undefined; - liftedSecrets.push({ node: n as { val: unknown }, raw }); - return { tag: 'secret', handle: GuestSecretHandle.fromRaw(SECRET_INTERNAL, raw) }; + let handle: GuestSecretHandle; + try { + handle = liftGuestSecretHandleFromWire(SECRET_INTERNAL, raw, n); + } catch (error) { + (n as { val: unknown }).val = raw; + throw error; + } + liftedCapabilities.push({ tag: 'secret', node: n as { val: unknown }, handle }); + return { tag: 'secret', handle }; } case 'quota-token-handle': { // Lift the owned `own` resource into an opaque take-once @@ -1471,7 +1535,9 @@ export function schemaValueFromWit(wit: WitSchemaValueTree): SchemaValue { throw new SchemaDecodeError('quota-token handle referenced more than once'); } (n as { val: unknown }).val = undefined; - return { tag: 'quota-token', handle: GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, raw) }; + const handle = liftGuestQuotaTokenHandleFromWire(QUOTA_INTERNAL, raw, n); + liftedCapabilities.push({ tag: 'quota-token', handle }); + return { tag: 'quota-token', handle }; } case 'stream-value': { const raw = n.val as typeof n.val | undefined; @@ -1490,10 +1556,9 @@ export function schemaValueFromWit(wit: WitSchemaValueTree): SchemaValue { throw new SchemaDecodeError('permission-card handle referenced more than once'); } (n as { val: unknown }).val = undefined; - return { - tag: 'permission-card', - handle: GuestPermissionCardHandle.fromRaw(PERMISSION_CARD_INTERNAL, raw), - }; + const handle = liftGuestPermissionCardHandleFromWire(PERMISSION_CARD_INTERNAL, raw, n); + liftedCapabilities.push({ tag: 'permission-card', handle }); + return { tag: 'permission-card', handle }; } default: throw new SchemaDecodeError( @@ -1506,8 +1571,19 @@ export function schemaValueFromWit(wit: WitSchemaValueTree): SchemaValue { try { result = fromIdx(wit.root); } catch (e) { - for (const lifted of liftedSecrets) { - lifted.node.val = lifted.raw; + for (let i = liftedCapabilities.length - 1; i >= 0; i--) { + const lifted = liftedCapabilities[i]!; + switch (lifted.tag) { + case 'secret': + lifted.node.val = releaseGuestSecretHandle(SECRET_INTERNAL, lifted.handle); + break; + case 'quota-token': + takeGuestQuotaTokenHandle(QUOTA_INTERNAL, lifted.handle); + break; + case 'permission-card': + takeGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, lifted.handle); + break; + } } // On failure, release any handles still owned by the wire tree so a caught // error cannot leave live owned quota-token or permission-card resources dangling in the @@ -1546,7 +1622,17 @@ export function drainUnconsumedQuotaAndPermissionCardHandles( (node as { val: unknown }).val !== undefined ) { if (first === undefined) first = i; - (node as { val: unknown }).val = undefined; + if (node.tag === 'quota-token-handle') { + abandonGuestQuotaTokenWireHandle(QUOTA_INTERNAL, node.val, node); + } else if (node.tag === 'permission-card-handle') { + abandonGuestPermissionCardWireHandle(PERMISSION_CARD_INTERNAL, node.val, node); + } + try { + (node as { val: unknown }).val = undefined; + } catch { + // The ownership ledger still marks the raw handle transferred even if + // a malformed non-writable carrier prevents clearing its last JS reference. + } } } return first; diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/tool/invocationResult.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/tool/invocationResult.ts index 89d428c5a6..f0bde0b65a 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/tool/invocationResult.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/tool/invocationResult.ts @@ -13,8 +13,13 @@ // limitations under the License. import type { TypedSchemaValue } from 'golem:tool/common@0.1.0'; -import { sourceValueIsCanonical, type SchemaCodec } from '../../schema/codec'; -import { t, typedSchemaValueToWit, v } from '../schema-model'; +import { + relinquishSchemaValueCapabilities, + sourceValueIsCanonical, + type SchemaCodec, +} from '../../schema/codec'; +import { t, typedSchemaValueToWit, type SchemaValue, v } from '../schema-model'; +import { withCapabilityAdoptionTransaction } from '../schema-model/capabilityTransaction'; import type { ExtendedErrorCase } from './model'; import { schemaValueConforms } from './validation'; @@ -38,8 +43,9 @@ export function encodeToolValue( value: unknown, position: string, ): TypedSchemaValue { + let encoded: SchemaValue | undefined; try { - const encoded = codec.toValue(value); + encoded = withCapabilityAdoptionTransaction(() => codec.toValue(value)); if (!schemaValueConforms(codec.graph, codec.graph.root, encoded)) { throw new Error('does not match its declared schema'); } @@ -48,6 +54,7 @@ export function encodeToolValue( } return typedSchemaValueToWit({ graph: codec.graph, value: encoded }); } catch (error) { + if (encoded !== undefined) relinquishSchemaValueCapabilities(encoded); throw invalidToolResult(`${position}: ${errorMessage(error)}`); } } diff --git a/sdks/ts/packages/golem-ts-sdk/src/internal/tool/model.ts b/sdks/ts/packages/golem-ts-sdk/src/internal/tool/model.ts index 07ab4d18ed..b1ac79b0d7 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/internal/tool/model.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/internal/tool/model.ts @@ -33,6 +33,8 @@ import { type TypedSchemaValue, v, validateSchemaGraph, + encodeChild, + isolateCapabilityRoot, } from '../schema-model'; import { CodecShapeMismatchError, type SchemaCodec } from '../../schema/codec'; import { toolBuildError } from './errors'; @@ -249,19 +251,20 @@ export class CanonicalInputModel { root: t.record(fields.map((entry) => field(entry.name, entry.codec.graph.root))), }; assertCanonicalGraph(graph, 'canonical input record'); + const toValue = (input: unknown) => { + const record = input as Record; + return v.record( + fields.map((entry) => { + if (!Object.prototype.hasOwnProperty.call(record, entry.name)) { + throw new Error(`missing canonical tool input field \`${entry.name}\``); + } + return encodeChild(entry.codec, record[entry.name]); + }), + ); + }; this.codec = { graph, - toValue: (input) => { - const record = input as Record; - return v.record( - fields.map((entry) => { - if (!Object.prototype.hasOwnProperty.call(record, entry.name)) { - throw new Error(`missing canonical tool input field \`${entry.name}\``); - } - return entry.codec.toValue(record[entry.name]); - }), - ); - }, + toValue: isolateCapabilityRoot(toValue), fromValue: (input) => { if (input.tag !== 'record') { throw new Error('tool input must be a positional record'); @@ -696,7 +699,9 @@ function isRepeatable(shape: ExtendedOptionShape): boolean { export function optionalCanonicalFieldCodec(inner: SchemaCodec): SchemaCodec { return { graph: inner.graph, - toValue: (input) => v.option(input === undefined ? undefined : inner.toValue(input)), + toValue: isolateCapabilityRoot((input) => + v.option(input === undefined ? undefined : encodeChild(inner, input)), + ), fromValue: (input) => { if (input.tag !== 'option') throw new Error('expected an optional tool input value'); return input.value === undefined ? undefined : inner.fromValue(input.value); @@ -755,15 +760,16 @@ export function resolveCodecRoot(codec: SchemaCodec) { } export function listCodec(itemCodec: SchemaCodec): SchemaCodec { + const toValue = (input: unknown) => { + if (!Array.isArray(input)) { + throw new CodecShapeMismatchError('expected a list source value'); + } + return v.list(input.map((item) => encodeChild(itemCodec, item))); + }; return { graph: { defs: itemCodec.graph.defs, root: t.list(itemCodec.graph.root) }, listItem: itemCodec, - toValue: (input) => { - if (!Array.isArray(input)) { - throw new CodecShapeMismatchError('expected a list source value'); - } - return v.list(input.map((item) => itemCodec.toValue(item))); - }, + toValue: isolateCapabilityRoot(toValue), fromValue: (input) => { if (input.tag !== 'list') throw new Error('expected a list schema value'); return input.elements.map((item) => itemCodec.fromValue(item)); diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/adapter.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/adapter.ts index b9c824068b..37156646c8 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/adapter.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/adapter.ts @@ -26,6 +26,10 @@ import { SchemaCodec, freezeSchemaCodec, SchemaWalker } from './codec'; import { isStandardSchema, type StandardSchemaV1 } from './standardSchema'; import { isMarkerSchema, WIT_MARKER } from './markers'; import { RecursionRegistry } from './recursion'; +import { + isolateCapabilityRoot, + withCapabilityAdoptionTransaction, +} from '../internal/schema-model/capabilityTransaction'; const walkers = new Map(); @@ -45,7 +49,8 @@ export function registeredVendors(): string[] { * the registry is threaded through the recursive walk (see {@link compileSchemaWith}). */ export function compileSchema(schema: unknown): SchemaCodec { - return freezeSchemaCodec(compileSchemaWith(schema, new RecursionRegistry())); + const codec = compileSchemaWith(schema, new RecursionRegistry()); + return freezeSchemaCodec({ ...codec, toValue: isolateCapabilityRoot(codec.toValue) }); } /** @@ -87,5 +92,9 @@ function compileSchemaWith(schema: unknown, registry: RecursionRegistry): Schema } function withSourceSchema(codec: SchemaCodec, schema: StandardSchemaV1): SchemaCodec { - return codec.sourceSchema === schema ? codec : { ...codec, sourceSchema: schema }; + return { + ...codec, + sourceSchema: schema, + toValue: (value) => withCapabilityAdoptionTransaction(() => codec.toValue(value)), + }; } diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/codec.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/codec.ts index cf7a965674..219c8ee128 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/codec.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/codec.ts @@ -25,11 +25,26 @@ import { SchemaType, SchemaValue, } from '../internal/schema-model'; -import { GuestSecretHandle } from '../internal/schema-model/secretHandle'; +import { + createUntrackedGuestSecretHandle, + peekGuestSecretHandle, + releaseGuestSecretHandle, + takeGuestSecretHandle, +} from '../internal/schema-model/secretHandle'; import { SECRET_INTERNAL } from '../internal/schema-model/secretInternal'; -import { GuestQuotaTokenHandle } from '../internal/schema-model/quotaTokenHandle'; +import { + createUntrackedGuestQuotaTokenHandle, + peekGuestQuotaTokenHandle, + releaseGuestQuotaTokenHandle, + takeGuestQuotaTokenHandle, +} from '../internal/schema-model/quotaTokenHandle'; import { QUOTA_INTERNAL } from '../internal/schema-model/quotaInternal'; -import { GuestPermissionCardHandle } from '../internal/schema-model/permissionCardHandle'; +import { + createUntrackedGuestPermissionCardHandle, + peekGuestPermissionCardHandle, + releaseGuestPermissionCardHandle, + takeGuestPermissionCardHandle, +} from '../internal/schema-model/permissionCardHandle'; import { PERMISSION_CARD_INTERNAL } from '../internal/schema-model/permissionCardInternal'; import type { PermissionCard as RawPermissionCard, @@ -190,9 +205,12 @@ export function sourceValueIsCanonical( return deepEqual(codec.fromValue(encoded), source); } - const probe = codec.toValue(source); + const sentinels = new Map(); + const probe = cloneWithSentinelHandles(encoded, sentinels); try { - return deepEqual(codec.fromValue(probe), source); + return deepEqual(source, codec.fromValue(probe), (raw, sentinel) => { + return sentinels.has(raw) && sentinels.get(raw) === sentinel; + }); } finally { drainCapabilityHandles(probe); } @@ -288,27 +306,30 @@ function cloneWithSentinelHandles( switch (value.tag) { case 'secret': { - const raw = value.handle.withHandle((handle) => handle); + const raw = peekGuestSecretHandle(SECRET_INTERNAL, value.handle); if (raw === undefined) throw new Error('secret handle was already transferred'); return { tag: 'secret', - handle: GuestSecretHandle.fromRaw(SECRET_INTERNAL, sentinelFor(raw) as RawSecret), + handle: createUntrackedGuestSecretHandle(SECRET_INTERNAL, sentinelFor(raw) as RawSecret), }; } case 'quota-token': { - const raw = value.handle.withHandle((handle) => handle); + const raw = peekGuestQuotaTokenHandle(QUOTA_INTERNAL, value.handle); if (raw === undefined) throw new Error('quota-token handle was already transferred'); return { tag: 'quota-token', - handle: GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, sentinelFor(raw) as RawQuotaToken), + handle: createUntrackedGuestQuotaTokenHandle( + QUOTA_INTERNAL, + sentinelFor(raw) as RawQuotaToken, + ), }; } case 'permission-card': { - const raw = value.handle.withHandle((handle) => handle); + const raw = peekGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value.handle); if (raw === undefined) throw new Error('permission-card handle was already transferred'); return { tag: 'permission-card', - handle: GuestPermissionCardHandle.fromRaw( + handle: createUntrackedGuestPermissionCardHandle( PERMISSION_CARD_INTERNAL, sentinelFor(raw) as RawPermissionCard, ), @@ -382,9 +403,13 @@ function cloneWithSentinelHandles( function drainCapabilityHandles(value: SchemaValue): void { switch (value.tag) { case 'secret': + takeGuestSecretHandle(SECRET_INTERNAL, value.handle); + return; case 'quota-token': + takeGuestQuotaTokenHandle(QUOTA_INTERNAL, value.handle); + return; case 'permission-card': - value.handle.take(); + takeGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value.handle); return; case 'record': value.fields.forEach(drainCapabilityHandles); @@ -416,3 +441,45 @@ function drainCapabilityHandles(value: SchemaValue): void { return; } } + +export function relinquishSchemaValueCapabilities(value: SchemaValue): void { + switch (value.tag) { + case 'secret': + releaseGuestSecretHandle(SECRET_INTERNAL, value.handle); + return; + case 'quota-token': + releaseGuestQuotaTokenHandle(QUOTA_INTERNAL, value.handle); + return; + case 'permission-card': + releaseGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value.handle); + return; + case 'record': + value.fields.forEach(relinquishSchemaValueCapabilities); + return; + case 'variant': + if (value.payload !== undefined) relinquishSchemaValueCapabilities(value.payload); + return; + case 'tuple': + case 'list': + case 'fixed-list': + value.elements.forEach(relinquishSchemaValueCapabilities); + return; + case 'map': + value.entries.forEach((entry) => { + relinquishSchemaValueCapabilities(entry.key); + relinquishSchemaValueCapabilities(entry.value); + }); + return; + case 'option': + if (value.value !== undefined) relinquishSchemaValueCapabilities(value.value); + return; + case 'result': + if (value.result.value !== undefined) relinquishSchemaValueCapabilities(value.result.value); + return; + case 'union': + relinquishSchemaValueCapabilities(value.body); + return; + default: + return; + } +} diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/markers.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/markers.ts index 13888cb76b..6f53b0261d 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/markers.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/markers.ts @@ -42,11 +42,23 @@ import { type NumericRestrictions, type Role, } from '../internal/schema-model'; -import { GuestSecretHandle } from '../internal/schema-model/secretHandle'; +import { + adoptGuestSecretHandle, + GuestSecretHandle, + releaseGuestSecretHandle, +} from '../internal/schema-model/secretHandle'; import { SECRET_INTERNAL } from '../internal/schema-model/secretInternal'; -import { GuestQuotaTokenHandle } from '../internal/schema-model/quotaTokenHandle'; +import { + adoptGuestQuotaTokenHandle, + GuestQuotaTokenHandle, + releaseGuestQuotaTokenHandle, +} from '../internal/schema-model/quotaTokenHandle'; import { QUOTA_INTERNAL } from '../internal/schema-model/quotaInternal'; -import { GuestPermissionCardHandle } from '../internal/schema-model/permissionCardHandle'; +import { + adoptGuestPermissionCardHandle, + GuestPermissionCardHandle, + releaseGuestPermissionCardHandle, +} from '../internal/schema-model/permissionCardHandle'; import { PERMISSION_CARD_INTERNAL } from '../internal/schema-model/permissionCardInternal'; import type { BinaryRestrictions, @@ -677,10 +689,10 @@ function secretMarker(inner: StandardSchemaV1): SecretMarkerSche graph: { ...innerCodec.graph, root: t.secret(innerCodec.graph.root) }, // Encode: wrap the freshly received owned `secret` resource in a // take-once handle. Decode: move the owned handle back out (take once). - toValue: (value) => v.secret(GuestSecretHandle.fromRaw(SECRET_INTERNAL, value as RawSecret)), + toValue: (value) => v.secret(adoptGuestSecretHandle(SECRET_INTERNAL, value as RawSecret)), fromValue: (sv) => { const handle = (sv as { tag: 'secret'; handle: GuestSecretHandle }).handle; - const raw = handle.take(); + const raw = releaseGuestSecretHandle(SECRET_INTERNAL, handle); if (raw === undefined) { throw new Error( 'secret handle was already consumed; an owned secret can only be decoded once', @@ -713,10 +725,10 @@ function quotaTokenMarker(): MarkerSchema { const descriptor: MarkerDescriptor = () => ({ graph: { defs: new Map(), root: t.quotaToken({}) }, toValue: (value) => - v.quotaToken(GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, value as RawQuotaToken)), + v.quotaToken(adoptGuestQuotaTokenHandle(QUOTA_INTERNAL, value as RawQuotaToken)), fromValue: (sv) => { const handle = (sv as { tag: 'quota-token'; handle: GuestQuotaTokenHandle }).handle; - const raw = handle.take(); + const raw = releaseGuestQuotaTokenHandle(QUOTA_INTERNAL, handle); if (raw === undefined) { throw new Error( 'quota-token handle was already consumed; an owned quota-token can only be decoded once', @@ -771,7 +783,7 @@ function permissionCardMarker(options: PermissionCardOptions): MarkerSchema v.permissionCard( - GuestPermissionCardHandle.fromRaw(PERMISSION_CARD_INTERNAL, value as RawPermissionCard), + adoptGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, value as RawPermissionCard), ), fromValue: (sv) => { const handle = ( @@ -780,7 +792,7 @@ function permissionCardMarker(options: PermissionCardOptions): MarkerSchema { throw new Error(`Expected a secret config value at '${d.path.join('.')}', got '${sv.tag}'`); } const handle = (sv as Extract).handle; - const revealedTree = handle.withHandle((raw) => reveal(raw, schemaGraphToWit(d.codec.graph))); - if (revealedTree === undefined) { + const raw = peekGuestSecretHandle(SECRET_INTERNAL, handle); + if (raw === undefined) { throw new Error(`Secret config handle at '${d.path.join('.')}' was already transferred`); } + const revealedTree = reveal(raw, schemaGraphToWit(d.codec.graph)); return d.codec.fromValue(schemaValueFromWit(revealedTree)) as T; } diff --git a/sdks/ts/packages/golem-ts-sdk/tests/bridge.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/bridge.test.ts index 058d1d1192..2195d7ff02 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/bridge.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/bridge.test.ts @@ -521,6 +521,95 @@ describe('public bridge runtime', () => { }); }); + it('keeps bridge capability conversions opaque and affine', () => { + const assertOpaque = (handle: unknown) => { + expect((handle as { take?: unknown }).take).toBeUndefined(); + expect((handle as { withHandle?: unknown }).withHandle).toBeUndefined(); + expect((handle as { isPresent?: unknown }).isPresent).toBeUndefined(); + }; + + const secretRaw = { id: 'secret' } as never; + const secret = bridge.secretHandleToSchemaValue(secretRaw); + assertOpaque(secret.handle); + expect(() => bridge.secretHandleToSchemaValue(secretRaw)).toThrow(/already owned/); + expect(() => + bridge.schemaValueFromWit({ + valueNodes: [{ tag: 'secret-value', val: secretRaw }], + root: 0, + }), + ).toThrow(/already owned/); + expect(bridge.secretHandleFromSchemaValue(secret)).toBe(secretRaw); + expect(() => bridge.secretHandleFromSchemaValue(secret)).toThrow(/already consumed/); + expect(() => bridge.secretHandleToSchemaValue(secretRaw)).not.toThrow(); + + const cardRaw = { id: 'permission-card' } as never; + const card = bridge.permissionCardHandleToSchemaValue(cardRaw); + assertOpaque(card.handle); + expect(() => bridge.permissionCardHandleToSchemaValue(cardRaw)).toThrow(/already owned/); + expect(() => + bridge.schemaValueFromWit({ + valueNodes: [{ tag: 'permission-card-handle', val: cardRaw }], + root: 0, + }), + ).toThrow(/already owned/); + expect(bridge.permissionCardHandleFromSchemaValue(card)).toBe(cardRaw); + expect(() => bridge.permissionCardHandleFromSchemaValue(card)).toThrow(/already consumed/); + + const quotaRaw = { id: 'quota-token' } as never; + const quotaValue = bridge.schemaValueFromWit({ + valueNodes: [{ tag: 'quota-token-handle', val: quotaRaw }], + root: 0, + }); + expect(quotaValue.tag).toBe('quota-token'); + if (quotaValue.tag !== 'quota-token') throw new Error('expected quota-token'); + assertOpaque(quotaValue.handle); + const quotaAliasTree = { + valueNodes: [{ tag: 'quota-token-handle' as const, val: quotaRaw }], + root: 0, + }; + expect(() => bridge.schemaValueFromWit(quotaAliasTree)).toThrow(/already owned/); + + let fakeKey: unknown; + const fakeToken = { + _toSchemaValue: (key: unknown) => { + fakeKey = key; + return quotaValue; + }, + } as never; + expect(() => bridge.quotaTokenToSchemaValue(fakeToken)).toThrow(/invalid quota token/); + expect(fakeKey).toBeUndefined(); + + let intercepted = false; + const quotaClass = bridge.QuotaToken as unknown as Record; + const quotaPrototype = bridge.QuotaToken.prototype as unknown as Record; + quotaClass._fromSchemaValue = () => { + intercepted = true; + }; + quotaPrototype._toSchemaValue = () => { + intercepted = true; + }; + try { + const token = bridge.quotaTokenFromSchemaValue(quotaValue); + const alias = bridge.quotaTokenFromSchemaValue(quotaValue); + const encoded = bridge.quotaTokenToSchemaValue(token); + expect(intercepted).toBe(false); + const wire = bridge.schemaValueToWit(encoded); + expect(() => + bridge.schemaValueFromWit({ + valueNodes: [{ tag: 'quota-token-handle', val: quotaRaw }], + root: 0, + }), + ).toThrow(/already owned/); + expect(bridge.schemaValueFromWit(wire).tag).toBe('quota-token'); + expect(() => bridge.schemaValueToWit(bridge.quotaTokenToSchemaValue(alias))).toThrow( + /already transferred/, + ); + } finally { + delete quotaClass._fromSchemaValue; + delete quotaPrototype._toSchemaValue; + } + }); + it('rejects custom-error payloads whose rich value records are malformed', () => { const payload = { graph: bridge.schemaGraphToWit({ diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-model/edge-cases.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-model/edge-cases.test.ts index 3a2401b8a3..fb7b6d0ce1 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-model/edge-cases.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-model/edge-cases.test.ts @@ -22,9 +22,15 @@ import type { } from 'golem:core/types@2.0.0'; import { - GuestSecretHandle, - GuestQuotaTokenHandle, - GuestPermissionCardHandle, + createGuestSecretHandle, + createGuestQuotaTokenHandle, + createGuestPermissionCardHandle, + peekGuestSecretHandle, + peekGuestQuotaTokenHandle, + peekGuestPermissionCardHandle, + takeGuestSecretHandle, + takeGuestQuotaTokenHandle, + takeGuestPermissionCardHandle, SchemaBuilder, SchemaEncodeError, classifyDiscriminatorPair, @@ -279,18 +285,18 @@ describe('rich semantic and capability values', () => { it('secret handle is lowered once and lifted back as an opaque handle', () => { const raw = { id: 'opaque-secret' } as never; - const handle = GuestSecretHandle.fromRaw(SECRET_INTERNAL, raw); - expect(handle.isPresent()).toBe(true); + const handle = createGuestSecretHandle(SECRET_INTERNAL, raw); + expect(peekGuestSecretHandle(SECRET_INTERNAL, handle)).toBe(raw); const wit = schemaValueToWit(v.secret(handle)); expect(wit.valueNodes[wit.root]).toEqual({ tag: 'secret-value', val: raw }); - expect(handle.isPresent()).toBe(false); + expect(peekGuestSecretHandle(SECRET_INTERNAL, handle)).toBeUndefined(); const decoded = schemaValueFromWit(wit); expect(decoded.tag).toBe('secret'); if (decoded.tag === 'secret') { - expect(decoded.handle.isPresent()).toBe(true); - expect(decoded.handle.take()).toBe(raw); + expect(peekGuestSecretHandle(SECRET_INTERNAL, decoded.handle)).toBe(raw); + expect(takeGuestSecretHandle(SECRET_INTERNAL, decoded.handle)).toBe(raw); } }); @@ -298,30 +304,30 @@ describe('rich semantic and capability values', () => { // `own` is opaque; a plain sentinel object stands in for the // generated resource handle. const raw = { id: 'opaque-quota-token' } as never; - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, raw); - expect(handle.isPresent()).toBe(true); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, raw); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBe(raw); const wit = schemaValueToWit(v.quotaToken(handle)); // Lowering moves the owned handle into a `quota-token-handle` wire node... expect(wit.valueNodes[wit.root]).toEqual({ tag: 'quota-token-handle', val: raw }); // ...and consumes the source handle (affine: send-once). - expect(handle.isPresent()).toBe(false); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBeUndefined(); const decoded = schemaValueFromWit(wit); expect(decoded.tag).toBe('quota-token'); if (decoded.tag === 'quota-token') { - expect(decoded.handle.isPresent()).toBe(true); - expect(decoded.handle.take()).toBe(raw); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, decoded.handle)).toBe(raw); + expect(takeGuestQuotaTokenHandle(QUOTA_INTERNAL, decoded.handle)).toBe(raw); } }); it('permission-card handles transfer once through nested schema values', () => { const raw = { id: 'opaque-permission-card' } as never; - const handle = GuestPermissionCardHandle.fromRaw(PERMISSION_CARD_INTERNAL, raw); + const handle = createGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, raw); const wit = schemaValueToWit(v.tuple([v.string('card'), v.permissionCard(handle)])); expect(wit.valueNodes).toContainEqual({ tag: 'permission-card-handle', val: raw }); - expect(handle.isPresent()).toBe(false); + expect(peekGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, handle)).toBeUndefined(); const decoded = schemaValueFromWit(wit); expect(decoded.tag).toBe('tuple'); @@ -329,17 +335,69 @@ describe('rich semantic and capability values', () => { const card = decoded.elements[1]; expect(card.tag).toBe('permission-card'); if (card.tag === 'permission-card') { - expect(card.handle.take()).toBe(raw); + expect(takeGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, card.handle)).toBe(raw); } } }); + it('a frozen secret wire node fails before claiming its raw handle', () => { + const raw = { id: 'frozen-secret' } as never; + const frozen: WitSchemaValueTree = { + valueNodes: [Object.freeze({ tag: 'secret-value', val: raw })], + root: 0, + }; + + expect(() => schemaValueFromWit(frozen)).toThrow(TypeError); + + const mutable: WitSchemaValueTree = { + valueNodes: [{ tag: 'secret-value', val: raw }], + root: 0, + }; + const decoded = schemaValueFromWit(mutable); + expect(decoded.tag).toBe('secret'); + if (decoded.tag === 'secret') { + expect(takeGuestSecretHandle(SECRET_INTERNAL, decoded.handle)).toBe(raw); + } + }); + + it('a frozen quota-token wire node is dropped without creating a hidden owner', () => { + const raw = { id: 'frozen-quota-token' } as never; + const frozen: WitSchemaValueTree = { + valueNodes: [Object.freeze({ tag: 'quota-token-handle', val: raw })], + root: 0, + }; + + expect(() => schemaValueFromWit(frozen)).toThrow(TypeError); + + const mutable: WitSchemaValueTree = { + valueNodes: [{ tag: 'quota-token-handle', val: raw }], + root: 0, + }; + expect(() => schemaValueFromWit(mutable)).toThrow(/already owned/); + }); + + it('a frozen permission-card wire node is dropped without creating a hidden owner', () => { + const raw = { id: 'frozen-permission-card' } as never; + const frozen: WitSchemaValueTree = { + valueNodes: [Object.freeze({ tag: 'permission-card-handle', val: raw })], + root: 0, + }; + + expect(() => schemaValueFromWit(frozen)).toThrow(TypeError); + + const mutable: WitSchemaValueTree = { + valueNodes: [{ tag: 'permission-card-handle', val: raw }], + root: 0, + }; + expect(() => schemaValueFromWit(mutable)).toThrow(/already owned/); + }); + it('permission-card encoding rejects aliases atomically and consumed handles', () => { - const handle = GuestPermissionCardHandle.fromRaw(PERMISSION_CARD_INTERNAL, {} as never); + const handle = createGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, {} as never); const aliased = v.tuple([v.permissionCard(handle), v.permissionCard(handle)]); expect(() => schemaValueToWit(aliased)).toThrow(/more than once/); - expect(handle.isPresent()).toBe(true); + expect(peekGuestPermissionCardHandle(PERMISSION_CARD_INTERNAL, handle)).toBeDefined(); schemaValueToWit(v.permissionCard(handle)); expect(() => schemaValueToWit(v.permissionCard(handle))).toThrow(/already transferred/); @@ -377,24 +435,24 @@ describe('rich semantic and capability values', () => { }); it('encoding an already-transferred quota-token handle is rejected', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); schemaValueToWit(v.quotaToken(handle)); expect(() => schemaValueToWit(v.quotaToken(handle))).toThrow(/already transferred/); }); it('aliasing one quota-token handle twice in a tree is rejected without transferring it', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); const aliased: SchemaValue = { tag: 'record', fields: [v.quotaToken(handle), v.quotaToken(handle)], }; expect(() => schemaValueToWit(aliased)).toThrow(/more than once/); // The preflight rejects before any handle is moved out (atomic lowering). - expect(handle.isPresent()).toBe(true); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBeDefined(); }); it('encoding a tree where a sibling fails leaves the quota-token handle untransferred', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); // Tuple([quota-token, datetime-with-invalid-nanoseconds]): the datetime would // be rejected by the boundary, so the encode preflight must fail before the // affine handle is moved out of its cell. @@ -406,17 +464,17 @@ describe('rich semantic and capability values', () => { ], }; expect(() => schemaValueToWit(tree)).toThrow(/datetime/); - expect(handle.isPresent()).toBe(true); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBeDefined(); }); it('rejects a sparse model list before transferring a quota-token sibling', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); const fields = new Array(2); fields[1] = v.quotaToken(handle); const tree: SchemaValue = { tag: 'record', fields }; expect(() => schemaValueToWit(tree)).toThrow(SchemaEncodeError); - expect(handle.isPresent()).toBe(true); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBeDefined(); }); it('decoding a tree where a later node is invalid neither lifts nor leaks the quota-token handle', () => { @@ -449,7 +507,7 @@ describe('rich semantic and capability values', () => { }); it('encoding a tree with an unknown sibling tag leaves the quota-token handle untransferred', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); // Tuple([quota-token, {tag:'bogus'}]): the unknown tag is rejected by the // encode preflight before the affine handle is moved out of its cell, rather // than later in `emitNode` after the take. @@ -458,7 +516,7 @@ describe('rich semantic and capability values', () => { elements: [v.quotaToken(handle), { tag: 'bogus' }], } as unknown as SchemaValue; expect(() => schemaValueToWit(tree)).toThrow(/unknown schema value tag/); - expect(handle.isPresent()).toBe(true); + expect(peekGuestQuotaTokenHandle(QUOTA_INTERNAL, handle)).toBeDefined(); }); it('decoding a tree with two nodes carrying the same raw quota resource is rejected', () => { @@ -495,7 +553,7 @@ describe('rich semantic and capability values', () => { }); it('quota-token handles cannot be serialized to JSON', () => { - const handle = GuestQuotaTokenHandle.fromRaw(QUOTA_INTERNAL, {} as never); + const handle = createGuestQuotaTokenHandle(QUOTA_INTERNAL, {} as never); expect(() => JSON.stringify(handle)).toThrow(/cannot be serialized/); }); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/sdk.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/sdk.test.ts index a00a1395fc..5937a96869 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/sdk.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/sdk.test.ts @@ -568,6 +568,32 @@ describe('RPC client', () => { expect('scheduleCancelable' in client.ping).toBe(false); }); + it('rolls back client input record capability adoption when a later field fails', () => { + const def = defineAgent({ + name: 'CapabilityInputAgent', + id: {}, + methods: { + send: method({ + input: { capability: s.secret(z.string()), later: z.string() }, + returns: z.void(), + }), + }, + }); + const raw = { id: 'client-capability' } as never; + const capability = compileSchema(s.secret(z.string())); + const client = clientFor(def)({}); + + expect(() => + client.send.trigger({ + capability: raw, + get later() { + throw new Error('client record failed'); + }, + }), + ).toThrow('client record failed'); + expect(capability.fromValue(capability.toValue(raw))).toBe(raw); + }); + it('uses one logical client for ephemeral invocations and returns final identity metadata', async () => { const ephemeralDef = defineAgent({ name: 'EphemeralClientTestAgent', diff --git a/sdks/ts/packages/golem-ts-sdk/tests/tool-model.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/tool-model.test.ts index c8e32018bd..32f21eddf2 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/tool-model.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/tool-model.test.ts @@ -45,6 +45,7 @@ import { graftSubtree, listCodec, normalizeExtendedTool, + optionalCanonicalFieldCodec, schemaValueConforms, validateExtendedTool, } from '../src/internal/tool'; @@ -1373,6 +1374,143 @@ describe('internal extended tool model', () => { }, ); + it.each([ + ['secret', s.secret(z.string())], + ['quota-token', s.quotaToken()], + ['permission-card', s.permissionCard({ polymorphic: false })], + ] as const)('rolls back partial %s adoption in synthetic list codecs', (_name, schema) => { + const raw = {} as never; + const item = compileSchema(schema); + const list = listCodec(item); + expect(() => list.toValue([raw, raw])).toThrow(/already owned/); + expect(item.fromValue(item.toValue(raw))).toBe(raw); + }); + + it.each([ + ['secret', s.secret(z.string())], + ['quota-token', s.quotaToken()], + ['permission-card', s.permissionCard({ polymorphic: false })], + ] as const)('rolls back present optional %s fields on later failure', (_name, schema) => { + const raw = {} as never; + const capability = compileSchema(schema); + const canonical = new CanonicalInputModel([ + { name: 'capability', aliases: [], codec: optionalCanonicalFieldCodec(capability) }, + { name: 'later', aliases: [], codec: stringCodec }, + ]); + expect(() => + canonical.encode({ + capability: raw, + get later() { + throw new Error('later failed'); + }, + }), + ).toThrow('later failed'); + expect(capability.fromValue(capability.toValue(raw))).toBe(raw); + }); + + it('does not roll back a successful reentrant capability conversion', () => { + const outerRaw = { id: 'outer' } as never; + const reentrantRaw = { id: 'reentrant' } as never; + const capability = compileSchema(s.secret(z.string())); + const outer = compileSchema(z.object({ capability: s.secret(z.string()), later: z.string() })); + let reentrantValue: ReturnType | undefined; + + expect(() => + outer.toValue({ + capability: outerRaw, + get later() { + reentrantValue = capability.toValue(reentrantRaw); + throw new Error('outer conversion failed'); + }, + }), + ).toThrow('outer conversion failed'); + + expect(reentrantValue).toBeDefined(); + expect(capability.fromValue(reentrantValue!)).toBe(reentrantRaw); + expect(capability.fromValue(capability.toValue(outerRaw))).toBe(outerRaw); + }); + + it('isolates successful reentry through the same compiled root codec', () => { + const outerRaw = { id: 'outer' } as never; + const reentrantRaw = { id: 'reentrant' } as never; + const codec = compileSchema(z.object({ capability: s.secret(z.string()), later: z.string() })); + let reentrantValue: ReturnType | undefined; + + expect(() => + codec.toValue({ + capability: outerRaw, + get later() { + reentrantValue = codec.toValue({ capability: reentrantRaw, later: 'ok' }); + throw new Error('outer conversion failed'); + }, + }), + ).toThrow('outer conversion failed'); + + expect(codec.fromValue(reentrantValue!)).toEqual({ capability: reentrantRaw, later: 'ok' }); + expect(codec.fromValue(codec.toValue({ capability: outerRaw, later: 'retry' }))).toEqual({ + capability: outerRaw, + later: 'retry', + }); + }); + + it('restores the parent journal after a caught failed reentrant root conversion', () => { + const first = { id: 'first' } as never; + const failed = { id: 'failed' } as never; + const after = { id: 'after' } as never; + const capability = compileSchema(s.secret(z.string())); + const outer = compileSchema( + z.object({ + first: s.secret(z.string()), + trigger: z.string(), + after: s.secret(z.string()), + final: z.string(), + }), + ); + + expect(() => + outer.toValue({ + first, + get trigger() { + expect(() => listCodec(capability).toValue([failed, failed])).toThrow(/already owned/); + return 'continue'; + }, + after, + get final() { + throw new Error('parent failed'); + }, + }), + ).toThrow('parent failed'); + + for (const raw of [first, failed, after]) { + expect(capability.fromValue(capability.toValue(raw))).toBe(raw); + } + }); + + it('rolls back nested synthetic and canonical root conversions atomically', () => { + const nestedRaw = { id: 'nested' } as never; + const canonicalRaw = { id: 'canonical' } as never; + const capability = compileSchema(s.secret(z.string())); + const nested = listCodec(listCodec(capability)); + const canonical = new CanonicalInputModel([ + { name: 'capability', aliases: [], codec: capability }, + { name: 'later', aliases: [], codec: stringCodec }, + ]); + + expect(() => nested.toValue([[nestedRaw, nestedRaw]])).toThrow(/already owned/); + expect(() => + canonical.encode({ + capability: canonicalRaw, + get later() { + throw new Error('canonical failed'); + }, + }), + ).toThrow('canonical failed'); + + for (const raw of [nestedRaw, canonicalRaw]) { + expect(capability.fromValue(capability.toValue(raw))).toBe(raw); + } + }); + it('tries a peeled value-is codec after the whole list codec rejects a scalar', () => { const codec = listCodec(stringCodec); const tool = new ExtendedToolType( diff --git a/sdks/ts/packages/golem-ts-sdk/tests/tool-registry.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/tool-registry.test.ts index a67a1b7282..52a4f9f1d0 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/tool-registry.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/tool-registry.test.ts @@ -30,6 +30,7 @@ import { ToolRegistry } from '../src/internal/registry/toolRegistry'; import { CanonicalInputModel } from '../src/internal/tool'; import { t, typedSchemaValueFromWit, typedSchemaValueToWit, v } from '../src/internal/schema-model'; import { tool } from '../src'; +import { encodeToolValue } from '../src/internal/tool/invocationResult'; import type { ByteStreamFailure } from 'golem:tool/host@0.1.0'; const streamFailures = [ @@ -1079,6 +1080,75 @@ describe('tool guest exports', () => { expect(commandNode.body?.result?.codec.fromValue(decoded.value)).toBe(raw); }); + it('releases a permission card after a non-canonical result so it can be retried', async () => { + const raw = { id: 'retry-permission-card' } as never; + let attempt = 0; + toolDefinition('permission-card-result-retry') + .body((body) => body.returns(z.object({ card: s.permissionCard({ polymorphic: false }) }))) + .implement({ + 'permission-card-result-retry': async () => + ok(attempt++ === 0 ? ({ card: raw, extra: true } as never) : { card: raw }), + }); + const registered = ToolRegistry.get('permission-card-result-retry'); + const commandNode = registered?.extended.commandByPath([]); + if (!registered || !commandNode) { + throw new Error('permission-card-result-retry was not registered'); + } + const invoke = () => + tool.invoke( + 'permission-card-result-retry', + [], + typedSchemaValueToWit(registered.extended.canonicalInputModel(commandNode).encodeTyped({})), + undefined, + undefined, + { tag: 'anonymous' }, + ); + + await expect(invoke()).rejects.toEqual({ + tag: 'invalid-result', + val: 'tool result: is not canonical for its declared schema', + }); + + const result = await invoke(); + expect(result.result).toBeDefined(); + const decoded = typedSchemaValueFromWit(result.result!); + expect(commandNode.body?.result?.codec.fromValue(decoded.value)).toEqual({ card: raw }); + }); + + it.each([ + ['secret', s.secret(z.string())], + ['quota-token', s.quotaToken()], + ['permission-card', s.permissionCard({ polymorphic: false })], + ] as const)('rolls back partial %s result adoption before retry', (_name, schema) => { + const raw = {} as never; + const codec = compileSchema(z.array(schema)); + expect(() => encodeToolValue(codec, [raw, raw], 'tool result')).toThrow(); + const result = encodeToolValue(codec, [raw], 'tool result'); + expect(codec.fromValue(typedSchemaValueFromWit(result).value)).toEqual([raw]); + }); + + it.each([ + ['secret', s.secret(z.string())], + ['quota-token', s.quotaToken()], + ['permission-card', s.permissionCard({ polymorphic: false })], + ] as const)('rolls back %s adoption when a later property getter throws', (_name, schema) => { + const raw = {} as never; + const codec = compileSchema(z.object({ capability: schema, later: z.string() })); + expect(() => + codec.toValue({ + capability: raw, + get later() { + throw new Error('getter failed'); + }, + }), + ).toThrow('getter failed'); + const result = encodeToolValue(codec, { capability: raw, later: 'ok' }, 'tool result'); + expect(codec.fromValue(typedSchemaValueFromWit(result).value)).toEqual({ + capability: raw, + later: 'ok', + }); + }); + it('delivers an owned permission-card input to a tool handler', async () => { const raw = { id: 'opaque-permission-card-input' } as never; const handler = vi.fn(async ({ card }: { card: typeof raw }) => { diff --git a/test-components/agent-sdk-rust/src/capabilities.rs b/test-components/agent-sdk-rust/src/capabilities.rs index 1ec56d7efc..269c13df22 100644 --- a/test-components/agent-sdk-rust/src/capabilities.rs +++ b/test-components/agent-sdk-rust/src/capabilities.rs @@ -12,12 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Echo agent exercising the capability `Secret` schema type end-to-end. -//! The `QuotaToken` capability is exercised separately via the -//! `quota_rpc` agents. +//! Agents exercising host-managed capability schema types end-to-end. +use golem_rust::agentic::{Config, Secret}; +use golem_rust::bindings::golem::permissions::{derive, types}; +use golem_rust::bindings::golem::secrets::reveal; +use golem_rust::quota::QuotaToken; +use golem_rust::schema::wit::GuestPermissionCardHandle; use golem_rust::secrets::GuestSecretHandle; -use golem_rust::{agent_definition, agent_implementation}; +use golem_rust::{ + ConfigSchema, FromSchema, IntoSchema, SchemaValue, agent_definition, agent_implementation, + decode_schema_value, encode_schema_graph, encode_schema_value, +}; #[agent_definition] pub trait CapabilityEchoAgent { @@ -40,3 +46,190 @@ impl CapabilityEchoAgent for CapabilityEchoAgentImpl { value } } + +fn secret_id(secret: &GuestSecretHandle) -> Result { + secret + .with_handle(golem_rust::bindings::golem::secrets::types::id) + .map(|id| format!("{:02x?}", id.bytes)) + .ok_or_else(|| "secret handle has already been transferred".to_string()) +} + +fn reveal_secret(secret: &GuestSecretHandle) -> Result { + let graph = + golem_rust::schema::try_into_schema_graph::().map_err(|error| error.to_string())?; + let expected_type = encode_schema_graph(&graph).map_err(|error| error.to_string())?; + let value = secret + .with_handle(|handle| reveal::reveal(handle, &expected_type)) + .ok_or_else(|| "secret handle has already been transferred".to_string())? + .map_err(|error| format!("{error:?}"))?; + let value = decode_schema_value(value).map_err(|error| error.to_string())?; + String::from_value(&value).map_err(|error| error.to_string()) +} + +fn permission_card() -> Result { + let card = derive::derive_from_wallet(&[], &[], &[], &[], None) + .map_err(|error| format!("{error:?}"))?; + Ok(GuestPermissionCardHandle::new(card)) +} + +fn card_id(card: &GuestPermissionCardHandle) -> Result { + card.with_handle(|card| format!("{:02x?}", types::id(card).uuid)) + .ok_or_else(|| "permission-card handle has already been transferred".to_string()) +} + +#[agent_definition] +pub trait CapabilityRpcReceiver { + fn new(name: String) -> Self; + + async fn return_capabilities( + &self, + secret: GuestSecretHandle, + quota: QuotaToken, + card: GuestPermissionCardHandle, + ) -> ( + String, + String, + GuestSecretHandle, + QuotaToken, + GuestPermissionCardHandle, + ); +} + +pub struct CapabilityRpcReceiverImpl; + +#[agent_implementation] +impl CapabilityRpcReceiver for CapabilityRpcReceiverImpl { + fn new(_name: String) -> Self { + Self + } + + async fn return_capabilities( + &self, + secret: GuestSecretHandle, + quota: QuotaToken, + card: GuestPermissionCardHandle, + ) -> ( + String, + String, + GuestSecretHandle, + QuotaToken, + GuestPermissionCardHandle, + ) { + let secret_id = secret_id(&secret).expect("received secret is usable"); + let card_id = card_id(&card).expect("received permission card is usable"); + quota + .reserve(0) + .expect("received quota token is usable") + .commit(0); + (secret_id, card_id, secret, quota, card) + } +} + +#[derive(ConfigSchema)] +pub struct CapabilityRpcSenderConfig { + #[config_schema(secret)] + secret_path: Secret, +} + +#[agent_definition] +pub trait CapabilityRpcSender { + fn new(name: String, #[agent_config] config: Config) -> Self; + + async fn round_trip(&self, receiver_name: String) -> Result, String>; + + fn codec_rejections(&self) -> Result, String>; +} + +pub struct CapabilityRpcSenderImpl { + secret: GuestSecretHandle, +} + +#[agent_implementation] +impl CapabilityRpcSender for CapabilityRpcSenderImpl { + fn new(_name: String, #[agent_config] config: Config) -> Self { + Self { + secret: config + .get() + .expect("config access should be allowed") + .secret_path + .handle() + .expect("secret handle access should be allowed"), + } + } + + async fn round_trip(&self, receiver_name: String) -> Result, String> { + let expected_secret_id = secret_id(&self.secret)?; + let card = permission_card()?; + let expected_card_id = card_id(&card)?; + let quota = QuotaToken::new("capability-rpc", 1); + let client = CapabilityRpcReceiverClient::get(receiver_name); + let (receiver_secret_id, receiver_card_id, secret, quota, card) = client + .return_capabilities(self.secret.clone(), quota, card) + .await; + + quota + .reserve(0) + .map_err(|error| format!("{error:?}"))? + .commit(0); + let revealed = reveal_secret(&secret)?; + let returned_secret_id = secret_id(&secret)?; + let returned_card_id = card_id(&card)?; + Ok(vec![ + expected_secret_id, + receiver_secret_id, + returned_secret_id, + expected_card_id, + receiver_card_id, + returned_card_id, + revealed, + ]) + } + + fn codec_rejections(&self) -> Result, String> { + let alias_secret = self.secret.clone(); + let secret_alias = SchemaValue::Record { + fields: vec![alias_secret.to_value(), alias_secret.to_value()], + }; + let secret_alias_error = encode_schema_value(&secret_alias) + .expect_err("aliased secret must be rejected") + .to_string(); + + let quota = QuotaToken::new("capability-rpc", 1); + let quota_alias = SchemaValue::Record { + fields: vec![quota.to_value(), quota.to_value()], + }; + let quota_alias_error = encode_schema_value("a_alias) + .expect_err("aliased quota token must be rejected") + .to_string(); + encode_schema_value("a.to_value()).map_err(|error| error.to_string())?; + let quota_consumed_error = encode_schema_value("a.to_value()) + .expect_err("consumed quota token must be rejected") + .to_string(); + + let card = permission_card()?; + let card_alias = SchemaValue::Record { + fields: vec![card.to_value(), card.to_value()], + }; + let card_alias_error = encode_schema_value(&card_alias) + .expect_err("aliased permission card must be rejected") + .to_string(); + encode_schema_value(&card.to_value()).map_err(|error| error.to_string())?; + let card_consumed_error = encode_schema_value(&card.to_value()) + .expect_err("consumed permission card must be rejected") + .to_string(); + + let consumed_secret = self.secret.clone(); + encode_schema_value(&consumed_secret.to_value()).map_err(|error| error.to_string())?; + let secret_consumed_error = encode_schema_value(&consumed_secret.to_value()) + .expect_err("consumed secret must be rejected") + .to_string(); + Ok(vec![ + secret_alias_error, + secret_consumed_error, + quota_alias_error, + quota_consumed_error, + card_alias_error, + card_consumed_error, + ]) + } +}