diff --git a/MEMORY.md b/MEMORY.md index c0c090c..d366761 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -53,7 +53,7 @@ - 일반: 저장한 그래프에는 원본 명세·관찰의 출처와 버전을 연결하고, 변경 시 갱신한다. 인증 정보는 모델 입력과 그래프에 저장하지 않는다. - 일반: 다른 언어의 도구를 내부에서 자동 실행할 수 있다. Rust 재구현이나 MCP 사용 자체가 목표는 아니다. - 초기 구현: 바이너리에 포함한 Python worker를 stdin/stdout JSON으로 호출하고 uv가 Python 3.12·graph-tool-call 0.47.0을 자동 준비한다(2026-10-09 0.46.0에서 갱신, 이전 버전 collection은 재import 필요). 별도 서버는 없지만 현재 개발 버전은 uv가 필요하다. uv 포함 배포·OS별 검증은 남았다. -- 구현: agent loop의 저장 collection 자동 연결·검색·정확한 명세 조회와 연결된 GET 실행·로컬 인증 참조. 미구현: HTTP 계약 지원 확대, browser-use 연결, 화면 정보 변환 어댑터. +- 구현: agent loop의 저장 collection 자동 연결·검색·정확한 명세 조회와 연결된 GET·호스트 지정 POST 조회·로컬 인증 참조. HTTP query 배열과 Run별 bearer 확장은 아래 결과를 따른다. 미구현: 승인 있는 HTTP 쓰기, browser-use 연결, 화면 정보 변환 어댑터. ## 역할별 모델 연결: 설계 후보 @@ -169,3 +169,8 @@ - HTTP 확장2는 [PR #140](https://github.com/PlateerLab/xgen-cli/pull/140)로 main `4fbbcc9`에 머지했고 Fast/Linux 검사가 통과했다. - HTTP 확장3(ADR-0083, 구현·로컬 검증 완료): host operationId 목록으로 GET/POST 조회를 지정하고 POST JSON 본문을 원본 schema로 검증한다. Capability2.0.0/profilev3로 고정하며 목록 변경은 기존 http_connections drift로 거절한다. shipment/measurement loopback POST·잘못된 본문0회 요청·미지정POST0회·drift를 확인했다. 실제 외부 서비스 통합과 모델 품질은 아직 미검증이다. - HTTP 확장3 검증: Rust782개(4 ignored), Python148개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사 통과. 전체 Rust 검사 도중 별도 빌드가 xgen 실행 파일을 바꿔 기존 executable drift 검사1건이 거절됐고, 빌드와 분리해 다시 돌린 전체 검사는 통과했다. 독립 diff 검토의 가설은 원본 계약·승인 경계·실행 전 body 검증과 대조했으며 추가 재현된 결함은 없었다. + +- HTTP 확장3은 [PR #141](https://github.com/PlateerLab/xgen-cli/pull/141)로 머지했고 Fast/Linux PR 검사가 통과했다. +- HTTP 확장4(ADR-0084, 구현·로컬 검증 완료): run/resume의 연결별 bearer env override를 별도 map으로 받으며 저장된 연결 인증을 바꾸지 않는다. 참조만 manifest/profile에 결합하고 누락·변경·값 부재/invalid에서 fallback 없이 거절한다. shipment/sensor의 다른 토큰·연결 불변·상태/모델 비노출·토큰 회전·완료 replay와 echo 차단을 loopback에서 확인했다. 서버의 사용자 인증·대리 권한·Run 접근 제어·process/state 격리는 외부 호스트 책임이며 실제 통합은 미검증이다. +- HTTP 확장4 검증: Rust784개(4 ignored), pinned backend+컴파일 CLI Python153개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사 통과. 한 Run의 연결2개/서로 다른 토큰도 올바르게 전달됐고 모든 상태 파일·모델 요청에 값이 없었다. 참조만 profile에 결합하며 override 없는 기존 binding은 유지했다. 승인 있는 쓰기는 아직 구현하지 않았다. +- HTTP 확장4 변경 추적: [PR #142](https://github.com/PlateerLab/xgen-cli/pull/142), 구현 코드 `67d1d40`. PR의 CI 결과와 merge 상태를 정본으로 참조한다. diff --git a/crates/xgen-cli/src/allow_process.rs b/crates/xgen-cli/src/allow_process.rs index f55cc95..ad39cc4 100644 --- a/crates/xgen-cli/src/allow_process.rs +++ b/crates/xgen-cli/src/allow_process.rs @@ -19,6 +19,10 @@ const MAX_ALLOW_EXECUTABLES: usize = 64; const MAX_EXECUTABLE_SPEC_BYTES: usize = 8 * 1024; const MAX_PLANNER_HINT_BYTES: usize = 8 * 1024; +pub(crate) fn forwarded_environment_key(name: &str) -> bool { + SAFE_ENVIRONMENT_KEYS.contains(&name) +} + const SAFE_ENVIRONMENT_KEYS: &[&str] = &[ "PATH", "PATHEXT", diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 685964f..324eaa7 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -135,6 +135,8 @@ pub struct LocalRunRequest { pub allow_executables: Vec, pub allow_remote_model_egress: bool, pub allow_read: bool, + /// Per-invocation HTTP authentication environment references; values stay host-only. + pub bearer_env_for: std::collections::BTreeMap, pub allow_write: bool, pub allow_execute: bool, pub max_ticks: u32, @@ -179,6 +181,7 @@ impl LocalRunRequest { allow_executables: Vec::new(), allow_remote_model_egress: false, allow_read: false, + bearer_env_for: std::collections::BTreeMap::new(), allow_write: false, allow_execute: false, max_ticks: 32, @@ -204,6 +207,8 @@ pub struct LocalResumeRequest { pub allow_executables: Vec, pub allow_remote_model_egress: bool, pub allow_read: bool, + /// Per-invocation HTTP authentication environment references; values stay host-only. + pub bearer_env_for: std::collections::BTreeMap, pub allow_write: bool, pub allow_execute: bool, pub max_ticks: u32, @@ -849,6 +854,9 @@ impl PublicRunError { "Restore the original --allow-file and --allow-dir selections." } "model_profile" => "Restore the original model profile and inference options.", + "http_bearer_overrides" => { + "Repeat the original --bearer-env-for CONNECTION=ENV_NAME references; token values may be rotated locally." + } _ => { "Restore the original executable selections, binaries and safe process environment; the binding cannot be overridden." } @@ -1101,12 +1109,15 @@ where request.final_response_schema.as_ref(), request.completion_checks.as_deref(), )?; + crate::http_read::validate_override_credentials(&request.bearer_env_for, &http_connections) + .map_err(|()| PublicRunError::Configuration)?; let local_execution_profile_digest = execution_profile_with_discovery( &workspace, &catalog, process.as_ref(), &discovery_snapshots, &http_connections, + &request.bearer_env_for, )?; let run_id = generate_run_id().map_err(|_| PublicRunError::Internal)?; let manifest = RunManifest::new( @@ -1127,6 +1138,8 @@ where .with_tool_discovery_snapshots(discovery_snapshots) .map_err(|_| PublicRunError::Configuration)? .with_http_read_connections(http_connections) + .map_err(|_| PublicRunError::Configuration)? + .with_http_bearer_overrides(request.bearer_env_for.clone()) .map_err(|_| PublicRunError::Configuration)?; let manifest = bind_manifest_options(manifest, process.as_ref(), conversation_responses)? .with_final_response_schema(request.final_response_schema) @@ -1311,6 +1324,17 @@ where }); } + if &request.bearer_env_for != manifest.http_bearer_overrides() { + return Err(PublicRunError::ConfigurationMismatch { + field: "http_bearer_overrides", + changed_items: Vec::new(), + }); + } + crate::http_read::validate_override_credentials( + &request.bearer_env_for, + manifest.http_read_connections(), + ) + .map_err(|()| PublicRunError::Configuration)?; let workspace_path = request.workspace.ok_or(PublicRunError::Configuration)?; let workspace = WorkspaceRoot::open_ambient( &workspace_path, @@ -1356,6 +1380,7 @@ where process.as_ref(), manifest.tool_discovery_snapshots(), manifest.http_read_connections(), + manifest.http_bearer_overrides(), )? { let current = process @@ -1639,6 +1664,7 @@ fn continue_incomplete( } = local_tool_product(workspace, &catalog, process)?; let discovery = DiscoveryCatalog::saved(manifest.tool_discovery_snapshots().clone()) .and_then(|catalog| catalog.with_connections(manifest.http_read_connections().clone())) + .and_then(|catalog| catalog.with_bearer_overrides(manifest.http_bearer_overrides().clone())) .map_err(|()| PublicRunError::Integrity)?; let discovery = if discovery.is_empty() { discovery @@ -3125,6 +3151,7 @@ fn execution_profile_with_discovery( process: Option<&ProcessTooling>, snapshots: &graph_discovery::Snapshots, connections: &crate::http_read::Connections, + overrides: &crate::http_read::BearerOverrides, ) -> Result { let local_digest = local_execution_profile_digest(workspace, catalog, process)?; if snapshots.is_empty() { @@ -3132,6 +3159,7 @@ fn execution_profile_with_discovery( } let discovery = DiscoveryCatalog::saved(snapshots.clone()) .and_then(|catalog| catalog.with_connections(connections.clone())) + .and_then(|catalog| catalog.with_bearer_overrides(overrides.clone())) .map_err(|()| PublicRunError::Configuration)?; let mut profile = serde_json::json!({ "domain":"xgen.cli.local-and-tool-discovery-execution-profile/v1", @@ -3641,7 +3669,8 @@ mod tests { &catalog, None, &graph_discovery::Snapshots::new(), - &crate::http_read::Connections::new() + &crate::http_read::Connections::new(), + &crate::http_read::BearerOverrides::new() ) .unwrap(), local_execution_profile_digest(&workspace, &catalog, None).unwrap() @@ -3654,7 +3683,8 @@ mod tests { &catalog, None, &first, - &crate::http_read::Connections::new() + &crate::http_read::Connections::new(), + &crate::http_read::BearerOverrides::new() ) .unwrap(), execution_profile_with_discovery( @@ -3662,7 +3692,8 @@ mod tests { &catalog, None, &second, - &crate::http_read::Connections::new() + &crate::http_read::Connections::new(), + &crate::http_read::BearerOverrides::new() ) .unwrap() ); @@ -3673,6 +3704,50 @@ mod tests { assert_eq!(specs[1].0.metadata.id, graph_discovery::DESCRIBE); } + #[test] + fn http_profile_pins_override_references_and_keeps_default_binding() { + let root = tempdir().unwrap(); + let workspace = + WorkspaceRoot::open_ambient(root.path(), WorkspaceId::new(WORKSPACE_ID).unwrap()) + .unwrap(); + let catalog = LocalReadCatalog::build(&workspace, &[], &[".".into()]).unwrap(); + let snapshots = graph_discovery::Snapshots::from([("sample".into(), "a".repeat(64))]); + let connections = crate::http_read::Connections::from([("sample".into(), serde_json::from_value(json!({"format_version":1,"base_url":"https://example.test","allow_get":true,"bearer_env":null,"credential_ref":null})).unwrap())]); + let profile = |overrides: &crate::http_read::BearerOverrides| { + execution_profile_with_discovery( + &workspace, + &catalog, + None, + &snapshots, + &connections, + overrides, + ) + .unwrap() + }; + let first = + crate::http_read::BearerOverrides::from([("sample".into(), "REQUEST_A".into())]); + let second = + crate::http_read::BearerOverrides::from([("sample".into(), "REQUEST_B".into())]); + assert_ne!(profile(&first), profile(&second)); + assert_ne!( + profile(&first), + profile(&crate::http_read::BearerOverrides::new()) + ); + let saved = DiscoveryCatalog::saved(snapshots) + .unwrap() + .with_connections(connections) + .unwrap(); + assert_eq!( + saved.adapter(crate::http_read::READ).binding(), + saved + .clone() + .with_bearer_overrides(crate::http_read::BearerOverrides::new()) + .unwrap() + .adapter(crate::http_read::READ) + .binding() + ); + } + #[test] fn manifest_budget_extends_only_the_agent_loop() { assert_eq!( @@ -4041,6 +4116,7 @@ mod tests { allow_executables: Vec::new(), allow_remote_model_egress: false, allow_read: true, + bearer_env_for: std::collections::BTreeMap::new(), allow_write: false, allow_execute: false, max_ticks: 32, @@ -4100,6 +4176,7 @@ mod tests { allow_executables: vec![specification], allow_remote_model_egress: false, allow_read: false, + bearer_env_for: std::collections::BTreeMap::new(), allow_write: false, allow_execute: false, max_ticks: 32, diff --git a/crates/xgen-cli/src/graph_discovery.rs b/crates/xgen-cli/src/graph_discovery.rs index 280f055..e9d6ca2 100644 --- a/crates/xgen-cli/src/graph_discovery.rs +++ b/crates/xgen-cli/src/graph_discovery.rs @@ -65,6 +65,7 @@ pub(crate) struct DiscoveryCatalog { root: PathBuf, snapshots: Snapshots, connections: crate::http_read::Connections, + bearer_overrides: crate::http_read::BearerOverrides, /// Documentation script of each collection whose script differs from the run goal's. language: BTreeMap, } @@ -131,6 +132,7 @@ impl DiscoveryCatalog { root: crate::tool_catalog_directory().map_err(|_| ())?, snapshots, connections: crate::http_read::Connections::new(), + bearer_overrides: crate::http_read::BearerOverrides::new(), language: BTreeMap::new(), }) } @@ -194,6 +196,19 @@ impl DiscoveryCatalog { self.connections = connections; Ok(self) } + pub(crate) fn with_bearer_overrides( + mut self, + overrides: crate::http_read::BearerOverrides, + ) -> Result { + if !crate::http_read::valid_bearer_overrides(&overrides, &self.connections) { + return Err(()); + } + self.bearer_overrides = overrides; + Ok(self) + } + pub(crate) fn bearer_override(&self, name: &str) -> Option<&str> { + self.bearer_overrides.get(name).map(String::as_str) + } pub(crate) fn connection(&self, name: &str) -> Option<&crate::http_read::Connection> { self.connections.get(name) } @@ -298,7 +313,13 @@ pub(crate) struct DiscoveryAdapter { impl DiscoveryAdapter { pub(crate) fn binding(&self) -> InstanceBinding { let encoded = if self.operation == crate::http_read::READ { - serde_jcs::to_vec(&serde_json::json!({"snapshots":self.catalog.snapshots,"connections":self.catalog.connections,"profile":crate::http_read::PROFILE})).expect("finite host profile") + { + let mut binding = serde_json::json!({"snapshots":self.catalog.snapshots,"connections":self.catalog.connections,"profile":crate::http_read::PROFILE}); + if !self.catalog.bearer_overrides.is_empty() { + binding["bearer_overrides"] = json!(self.catalog.bearer_overrides); + } + serde_jcs::to_vec(&binding).expect("finite host profile") + } } else { serde_jcs::to_vec(&self.catalog.snapshots).expect("finite snapshot map") }; diff --git a/crates/xgen-cli/src/http_read.rs b/crates/xgen-cli/src/http_read.rs index 9a991a9..a5e0123 100644 --- a/crates/xgen-cli/src/http_read.rs +++ b/crates/xgen-cli/src/http_read.rs @@ -23,6 +23,39 @@ pub(crate) type Connections = BTreeMap; const MAX_BODY: usize = 32 * 1024; const MAX_TARGET_BYTES: usize = 8 * 1024; +pub(crate) type BearerOverrides = BTreeMap; + +pub(crate) fn valid_bearer_overrides( + overrides: &BearerOverrides, + connections: &Connections, +) -> bool { + overrides.len() <= 16 + && overrides.iter().all(|(name, variable)| { + connections.contains_key(name) + && !variable.is_empty() + && variable.len() <= 128 + && variable + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + && !crate::allow_process::forwarded_environment_key(variable) + }) +} + +pub(crate) fn validate_override_credentials( + overrides: &BearerOverrides, + connections: &Connections, +) -> Result<(), ()> { + if !valid_bearer_overrides(overrides, connections) { + return Err(()); + } + for (name, variable) in overrides { + connections[name] + .credential(Some(variable)) + .map_err(|_| ())?; + } + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Connection { @@ -78,8 +111,11 @@ impl Connection { pub(crate) fn policy(&self) -> Value { json!({"allow_get": self.allow_get, "read_operations": self.read_operations}) } - fn credential(&self) -> Result>, &'static str> { - let secret = if let Some(name) = &self.bearer_env { + fn credential( + &self, + override_env: Option<&str>, + ) -> Result>, &'static str> { + let secret = if let Some(name) = override_env.or(self.bearer_env.as_deref()) { Some(Zeroizing::new( std::env::var(name).map_err(|_| "http_credential_unavailable")?, )) @@ -559,7 +595,13 @@ pub(crate) fn prepare( return Err(AdapterPrepareFailure::UnsupportedProtocol); } let credential = connection - .credential() + .credential( + catalog.bearer_override( + input["collection"] + .as_str() + .ok_or(AdapterPrepareFailure::InvalidMaterial)?, + ), + ) .map_err(|_| AdapterPrepareFailure::ResourceUnavailable)?; if contract["bearer_required"] == true && credential.is_none() { return Err(AdapterPrepareFailure::ResourceUnavailable); diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 7d2170f..1f50340 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -283,6 +283,9 @@ struct RunArgs { /// Approve each one-shot read-only action selected within the declared file/directory scope. #[arg(long)] allow_read: bool, + /// Override one connection's bearer reference for this run; never saves the token. + #[arg(long, value_name = "CONNECTION=ENV_NAME", value_parser = parse_bearer_override)] + bearer_env_for: Vec<(String, String)>, /// Approve each one-shot atomic file write selected within an allow-dir scope. #[arg(long)] allow_write: bool, @@ -356,6 +359,9 @@ struct ResumeArgs { /// Approve each one-shot read-only action selected within the declared file/directory scope. #[arg(long)] allow_read: bool, + /// Override one connection's bearer reference for this run; never saves the token. + #[arg(long, value_name = "CONNECTION=ENV_NAME", value_parser = parse_bearer_override)] + bearer_env_for: Vec<(String, String)>, /// Approve each one-shot atomic file write selected within an allow-dir scope. #[arg(long)] allow_write: bool, @@ -741,6 +747,7 @@ impl repl::ReplHost for InteractiveHost { allow_executables: Vec::new(), allow_remote_model_egress: grants.model, allow_read: grants.read, + bearer_env_for: std::collections::BTreeMap::new(), allow_write: grants.write, allow_execute: grants.execute, max_ticks: repl_max_ticks(self.max_model_turns), @@ -790,6 +797,7 @@ impl repl::ReplHost for InteractiveHost { }, allow_remote_model_egress: grants.model, allow_read: grants.read, + bearer_env_for: std::collections::BTreeMap::new(), allow_write: grants.write, allow_execute: grants.execute, max_ticks: repl_max_ticks(self.max_model_turns), @@ -1153,6 +1161,37 @@ fn run_model_command(command: ModelCommand) -> ExitCode { } } +fn parse_bearer_override(value: &str) -> Result<(String, String), String> { + let (connection, variable) = value + .split_once('=') + .ok_or("expected CONNECTION=ENV_NAME")?; + if connection.is_empty() + || connection.len() > 64 + || !connection + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + || variable.is_empty() + || variable.len() > 128 + || !variable + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + return Err("expected a connection name and environment variable name".into()); + } + Ok((connection.into(), variable.into())) +} +fn bearer_overrides( + values: Vec<(String, String)>, +) -> Result, PublicRunError> { + let mut result = std::collections::BTreeMap::new(); + for (connection, variable) in values { + if result.insert(connection, variable).is_some() { + return Err(PublicRunError::Configuration); + } + } + Ok(result) +} + fn run_command(args: RunArgs, debug: bool) -> ExitCode { let mut final_response_schema = match args.response_schema { Some(path) => { @@ -1232,6 +1271,10 @@ fn run_command(args: RunArgs, debug: bool) -> ExitCode { allow_executables, allow_remote_model_egress: args.allow_remote_model_egress, allow_read: args.allow_read, + bearer_env_for: match bearer_overrides(args.bearer_env_for) { + Ok(value) => value, + Err(error) => return present(Err(error)), + }, allow_write: args.allow_write, allow_execute: args.allow_execute, max_ticks: args.max_ticks, @@ -1262,6 +1305,7 @@ fn resume_command(args: ResumeArgs, debug: bool) -> ExitCode { allow_path_executables, allow_remote_model_egress, allow_read, + bearer_env_for, allow_write, allow_execute, max_ticks, @@ -1278,6 +1322,10 @@ fn resume_command(args: ResumeArgs, debug: bool) -> ExitCode { allow_executables: executable_catalog(allow_executables, allow_path_executables), allow_remote_model_egress, allow_read, + bearer_env_for: match bearer_overrides(bearer_env_for) { + Ok(value) => value, + Err(error) => return present(Err(error)), + }, allow_write, allow_execute, max_ticks, diff --git a/crates/xgen-cli/src/manifest.rs b/crates/xgen-cli/src/manifest.rs index 68b6a46..f8ec069 100644 --- a/crates/xgen-cli/src/manifest.rs +++ b/crates/xgen-cli/src/manifest.rs @@ -37,6 +37,8 @@ struct RunManifestRecord { tool_discovery_snapshots: crate::graph_discovery::Snapshots, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] http_read_connections: crate::http_read::Connections, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + http_bearer_overrides: crate::http_read::BearerOverrides, budget: ManifestBudget, #[serde(default, skip_serializing_if = "std::ops::Not::not")] conversation_responses: bool, @@ -164,6 +166,7 @@ impl RunManifest { process_fingerprints: None, tool_discovery_snapshots: crate::graph_discovery::Snapshots::new(), http_read_connections: crate::http_read::Connections::new(), + http_bearer_overrides: crate::http_read::BearerOverrides::new(), budget, conversation_responses: false, final_response_schema: None, @@ -177,6 +180,19 @@ impl RunManifest { }) } + pub(crate) fn with_http_bearer_overrides( + mut self, + overrides: crate::http_read::BearerOverrides, + ) -> Result { + self.record.http_bearer_overrides = overrides; + validate_record(&self.record)?; + self.record_digest = digest_record(&self.record)?; + Ok(self) + } + pub(crate) fn http_bearer_overrides(&self) -> &crate::http_read::BearerOverrides { + &self.record.http_bearer_overrides + } + pub(crate) fn with_http_read_connections( mut self, connections: crate::http_read::Connections, @@ -380,7 +396,10 @@ fn validate_record(record: &RunManifestRecord) -> Result<(), ManifestError> { }) { return Err(ManifestError::Invalid); } - if !crate::http_read::valid_connections(&record.http_read_connections) + if !crate::http_read::valid_bearer_overrides( + &record.http_bearer_overrides, + &record.http_read_connections, + ) || !crate::http_read::valid_connections(&record.http_read_connections) || record .http_read_connections .keys() @@ -557,6 +576,50 @@ mod tests { assert!(RunManifest::from_bytes(&serde_json::to_vec(&value).unwrap()).is_err()); } + #[test] + fn bearer_references_are_optional_bound_and_validated_without_values() { + let legacy = fixture(); + assert!(legacy.http_bearer_overrides().is_empty()); + assert!( + !String::from_utf8(legacy.to_bytes().unwrap()) + .unwrap() + .contains("httpBearerOverrides") + ); + let bound = legacy.with_tool_discovery_snapshots(crate::graph_discovery::Snapshots::from([("sample".into(), "a".repeat(64))])).unwrap() + .with_http_read_connections(crate::http_read::Connections::from([("sample".into(), serde_json::from_value(serde_json::json!({"format_version":1,"base_url":"https://example.test","allow_get":true,"bearer_env":"STORED_TOKEN","credential_ref":null})).unwrap())])).unwrap(); + let with_override = bound + .clone() + .with_http_bearer_overrides(crate::http_read::BearerOverrides::from([( + "sample".into(), + "REQUEST_TOKEN".into(), + )])) + .unwrap(); + assert_ne!(bound.authority(), with_override.authority()); + assert_eq!( + RunManifest::from_bytes(&with_override.to_bytes().unwrap()).unwrap(), + with_override + ); + let mut altered: Value = + serde_json::from_slice(&with_override.to_bytes().unwrap()).unwrap(); + altered["record"]["httpBearerOverrides"]["sample"] = serde_json::json!("OTHER_TOKEN"); + assert!(RunManifest::from_bytes(&serde_json::to_vec(&altered).unwrap()).is_err()); + for (name, variable) in [ + ("unknown", "REQUEST_TOKEN"), + ("sample", "LANG"), + ("sample", "bad value"), + ] { + assert!( + bound + .clone() + .with_http_bearer_overrides(crate::http_read::BearerOverrides::from([( + name.into(), + variable.into() + )])) + .is_err() + ); + } + } + #[test] fn optional_diagnostics_keep_legacy_manifests_and_bind_new_hashes() { let legacy = fixture(); diff --git a/docs/adr/0083-host-selected-post-reads.md b/docs/adr/0083-host-selected-post-reads.md index 255f66c..213eb61 100644 --- a/docs/adr/0083-host-selected-post-reads.md +++ b/docs/adr/0083-host-selected-post-reads.md @@ -16,7 +16,7 @@ POST 본문은 OpenAPI requestBody의 application/json 단일 미디어 형식만 지원한다. 내부 ref를 해제한 원본 schema, required를 보존하고 native host가 전체 schema로 검증한다. Swagger2 body, -여러 미디어 형식은 지원하지 않는다. 본문 없는 POST는 원본에 requestBody가 없을 때만 가능하다. +여러 미디어 형식은 지원하지 않는다. 원본에 requestBody가 없거나 required=false이면 본문을 생략할 수 있다. 정의하지 않은 본문, 필수 본문 누락, schema 위반은 HTTP 전에 거절한다. 입력 전체8KiB 한도는 유지한다. `xgen.http/read@2.0.0`에 선택 입력 `body`를 추가한다. method는 모델 입력이 아니라 원본 계약에서 diff --git a/docs/adr/0084-run-bearer-overrides.md b/docs/adr/0084-run-bearer-overrides.md new file mode 100644 index 0000000..6866b30 --- /dev/null +++ b/docs/adr/0084-run-bearer-overrides.md @@ -0,0 +1,48 @@ +# ADR-0084: Run별 HTTP bearer 인증 참조 + +- 날짜: 2026-10-31 +- 상태: 채택, 구현·로컬 검증 완료 +- 범위: 일반 headless HTTP 인증 기능. 특정 서비스의 사용자·권한 규칙은 포함하지 않는다. + +## 결정 + +`run`/`resume`에 반복 가능한 `--bearer-env-for CONNECTION=ENV_NAME`을 추가한다. 연결별로 해당 +호출의 bearer 환경변수 참조를 선택하며, 저장된 연결·인증 참조·OS secret store는 바꾸지 않는다. +CLI 외부 호스트도 LocalRunRequest/LocalResumeRequest의 bearer_env_for map으로 참조를 전달한다. +대화형 REPL의 기본 연결 인증 방식은 유지한다. + +override가 있으면 저장된 인증보다 먼저 사용한다. 지정한 환경변수가 없거나 빈 값·잘못된 bearer면 +실행 전 거절하며 저장 토큰으로 fallback하지 않는다. 없는 연결·중복 CLI 지정·잘못된 이름도 거절한다. +이름만 허용하고 실제 bearer는 원래 인증과 같은 Zeroizing 값으로 잠시 보관한다. native host가 +Authorization 헤더를 붙이고 worker·material·Receipt·모델에 값을 전달하지 않는다. 프로세스 도구에 +전달되는 기존 safe environment 변수는 인증 원본으로 선택할 수 없다. 필터 목록을 중복 정의하지 않고 +기존 process 환경 선택 규칙에서 판정한다. + +manifest에 기본 생략 가능한 httpBearerOverrides 참조 map을 추가한다. execution profile의 HTTP +adapter binding도 참조 map에 결합한다. 토큰 값이나 값의 hash는 넣지 않는다. 미완료 Run의 resume은 +동일 map을 다시 지정해야 한다. 누락·변경은 http_bearer_overrides drift로 거절한다. 같은 env 이름의 +토큰 회전은 허용한다. 완료 Run은 기존처럼 이 검사보다 먼저 offline replay한다. + +override가 없으면 새 필드를 직렬화하지 않고 기존 adapter binding/profile을 유지한다. Capability +2.0.0과 HTTP profile v3를 다시 올리지 않는다. 새 동작은 명시된 참조 map으로만 profile에 들어간다. + +## 호스트 책임 + +서버는 사용자별 토큰을 각 자식 프로세스의 환경에 넣고 Run을 실행한다. 같은 프로세스의 전역 환경을 +요청마다 바꾸는 방식은 다중 사용자 격리가 아니다. Run ID 접근 제어·사용자 인증·사용자별 상태 저장소· +토큰 발급·대리 권한 검증은 호출 서버의 책임이다. 이 기능만으로 서버 통합이 완료됐다고 주장하지 않는다. + +## 검증 + +shipment(저장된 bearer 참조 있음)과 sensor(원래 인증 없음)를 별도 loopback API/결정적 모델로 +호출해 Run별 Authorization·연결 원문 불변을 확인한다. 한 Run에 연결 두 개와 서로 다른 토큰도 +검증한다. 실제 토큰 값이 모든 상태 파일·모델 요청에 없고 credential echo는 버려지는지 확인한다. + +승인 pause 이후 참조 누락·변경·환경변수 부재에서 HTTP0회, 동일 참조의 토큰 교체 뒤 성공, 완료 +Run의 환경변수 없는 replay에서 추가 API/모델0회를 검증한다. manifest 무결성·profile 참조 결합· +기본 empty map 호환성을 native unit 검사로 검증한다. 실제 외부 서비스·LLM 품질·서버 tenant 격리는 +측정하지 않는다. 기존 v1 HTTP binary의 미완료 거절·완료 replay 회귀도 유지한다. + +승인 있는 쓰기는 사용자 요청대로 대회 후 별도 설계다. + +Rust784개(4 ignored), pinned backend+컴파일 CLI의 Python153개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사를 통과했다. diff --git a/docs/development/http-read-tools.md b/docs/development/http-read-tools.md index cc6c9a9..266be2f 100644 --- a/docs/development/http-read-tools.md +++ b/docs/development/http-read-tools.md @@ -37,6 +37,27 @@ xgen tools remove --name my-system # collection, 연결, 저장된 토큰 - 연결 저장소가 사용자 전용이 아니거나, 연결 파일이 symlink거나, 저장소를 읽을 수 없으면(권한 오류를 "연결 없음"으로 보지 않는다) 아무것도 지우지 않고 실패한다. - 연결을 바꾸면 그 연결로 시작한 Run은 재개 시 기존처럼 거절된다. +## Run별 bearer 주입 + +```bash +xgen run --workspace . --allow-dir . --allow-read --allow-remote-model-egress \ + --bearer-env-for my-system=USER_API_TOKEN "연결된 API를 조회해줘" +xgen resume RUN_ID --workspace . --allow-dir . --allow-read --allow-remote-model-egress \ + --bearer-env-for my-system=USER_API_TOKEN +``` + +토큰은 호출 프로세스의 `USER_API_TOKEN`에 호스트가 설정한다. 인자에는 값 대신 **환경변수 이름**만 +넣는다. 여러 연결이면 옵션을 반복한다. 연결 파일의 bearer 참조는 바뀌지 않고 이 Run에서만 우선한다. +변수가 없거나 빈 값·잘못된 bearer면 저장 토큰으로 넘어가지 않고 거절한다. process 도구의 safe +환경으로 전달되는 `LANG`·`PATH` 같은 변수는 인증 참조로 사용할 수 없다. + +미완료 Run을 재개할 때 원래 참조들을 다시 지정해야 한다. map 누락·변경은 재개를 거절하고, 같은 +참조에서 토큰 값만 바꾸는 것은 가능하다. 값은 manifest·Receipt·모델에 남지 않는다. 완료 Run의 +재생은 참조 옵션이나 토큰 없이 가능하다. [ADR-0084](../adr/0084-run-bearer-overrides.md). + +서버는 사용자별 자식 프로세스 환경으로 주입한다. 공유 프로세스의 전역 환경을 요청마다 바꾸는 방식은 +사용자 격리가 아니다. 사용자 인증·대리 권한·Run 접근 제어·상태 저장소 격리는 호출 서버가 담당한다. + ## 호스트가 지정한 POST 조회 ```bash diff --git a/docs/development/tool-integration-todo.md b/docs/development/tool-integration-todo.md index 4abf72e..868e959 100644 --- a/docs/development/tool-integration-todo.md +++ b/docs/development/tool-integration-todo.md @@ -140,11 +140,12 @@ 1. **구현·로컬 검증:** 지원 밖 선택 파라미터 제외, required/path 거절, describe 제외 이유, 제외 입력 호출 전 거절. [ADR-0081](../adr/0081-optional-http-parameters.md), [PR #139](https://github.com/PlateerLab/xgen-cli/pull/139)로 main에 머지했다. 2. **구현·로컬 검증 완료:** OpenAPI 3 form/explode=true와 Swagger 2 multi 배열 query, scalar items 및 배열 제약 검증. - 다른 직렬화·object items는 1번의 제외/거절 규칙을 따른다. [ADR-0082](../adr/0082-array-query-parameters.md). + 다른 직렬화·object items는 1번의 제외/거절 규칙을 따른다. [ADR-0082](../adr/0082-array-query-parameters.md), [PR #140](https://github.com/PlateerLab/xgen-cli/pull/140)로 main에 머지했다. 3. **구현·로컬 검증 완료:** 연결별 반복 `--read-operation`으로 호스트가 지정한 POST 조회, JSON body 원본 schema 검증, - 지정 목록의 연결·execution profile 고정과 drift 거절. 지정하지 않은 POST는 거절한다. -4. **이후:** run/resume의 `--bearer-env-for CONNECTION=ENV_NAME`으로 저장된 연결을 바꾸지 않고 + 지정 목록의 연결·execution profile 고정과 drift 거절. 지정하지 않은 POST는 거절한다. [ADR-0083](../adr/0083-host-selected-post-reads.md), [PR #141](https://github.com/PlateerLab/xgen-cli/pull/141)로 main에 머지했다. +4. **구현·로컬 검증 완료:** run/resume의 `--bearer-env-for CONNECTION=ENV_NAME`으로 저장된 연결을 바꾸지 않고 요청별 bearer를 주입한다. 값은 manifest·Receipt·모델에 남기지 않는다. 참조 binding·resume 누락 시 - 저장 token으로의 암묵적 fallback·서버 process 격리도 검증한다. + 저장 token으로의 암묵적 fallback 차단을 검증했다. 서버 process/state 격리는 호출 호스트 책임이다. + [ADR-0084](../adr/0084-run-bearer-overrides.md), [PR #142](https://github.com/PlateerLab/xgen-cli/pull/142). 5. **별도 설계, 대회 후:** 승인 있는 POST/PUT/PATCH/DELETE 쓰기·host Idempotency-Key·NonIdempotent Unknown의 no-retry. 1~4 완료 뒤 진행하며 이번 조회 확장에 포함하지 않는다. diff --git a/scripts/tests/test_http_read_tools.py b/scripts/tests/test_http_read_tools.py index 990a47e..c68c55d 100644 --- a/scripts/tests/test_http_read_tools.py +++ b/scripts/tests/test_http_read_tools.py @@ -287,7 +287,8 @@ def do_GET(self): test.calls.append((self.path,self.headers.get("Authorization"))) test.accepted.set() if test.release: test.release.wait(timeout=30) - body=test.response if isinstance(test.response,bytes) else json.dumps(test.response).encode() + value=test.response(self.path) if callable(test.response) else test.response + body=value if isinstance(value,bytes) else json.dumps(value).encode() self.send_response(test.status) self.send_header("Content-Type",test.media) if test.redirect: self.send_header("Location",test.redirect) @@ -343,10 +344,10 @@ def plan(key,capability,args): def context(payload): return json.loads(next(m["content"] for m in payload["messages"] if m["role"]=="user"))["planningContext"] - def run_cli(self,allow=True,binary=None): + def run_cli(self,allow=True,binary=None,extra=()): args=["run","--workspace",str(self.workspace),"--base-url",f"http://127.0.0.1:{self.model.server_port}/v1","--model","fixture-model","--tokenizer","fixture-tokenizer","--allow-dir",".","--allow-remote-model-egress"] if allow: args += ["--allow-read"] - return self.cli(*args,"Read connected fixture API.",check=False,binary=binary) + return self.cli(*args,*extra,"Read connected fixture API.",check=False,binary=binary) def manifest(self): return json.loads(next((self.state/"runs").glob("*/manifest.json")).read_text()) @@ -559,6 +560,105 @@ def test_selected_post_operation_drift_rejects_resume(self): self.assertIn(b"configuration=http_connections",result.stderr) self.assertEqual(self.calls,[]);self.assertEqual(len(self.requests),1) + def assert_tokens_absent(self, tokens): + for path in self.state.rglob("*"): + if path.is_file(): + data=path.read_bytes() + for token in tokens: + self.assertFalse(token.encode() in data,"credential persisted in state") + serialized=json.dumps(self.requests).encode() + for token in tokens: + self.assertFalse(token.encode() in serialized,"credential reached model") + + def test_run_bearer_override_changes_neither_connection_nor_persisted_material(self): + tokens=[] + for name in ("shipments","sensors"): + args=self.install(name) + path=self.state/"http-connections"/(name+".json");original=path.read_bytes() + self.response={"tracking":"A42","delivered":True} if name=="shipments" else {"values":[2.5]} + for variable in ("FIXTURE_REQUEST_A","FIXTURE_REQUEST_B"): + token=secrets.token_hex(24);tokens.append(token);self.env[variable]=token + self.requests.clear();self.calls.clear();self.proposals=[self.plan("read","xgen.http/read",args)] + result=self.run_cli(extra=("--bearer-env-for",name+"="+variable)) + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertEqual(len(self.calls),1) + self.assertTrue(self.calls[0][1]=="Bearer "+token,"override authorization mismatch") + self.assertEqual(path.read_bytes(),original) + self.assert_tokens_absent(tokens) + self.requests.clear();self.calls.clear();self.proposals=[self.plan("read","xgen.http/read",args)] + result=self.run_cli() + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertIsNone(self.calls[0][1]) + + def test_multiple_connections_use_separate_run_bearers(self): + shipment=self.install();sensor=self.install("sensors") + tokens=[secrets.token_hex(24),secrets.token_hex(24)] + self.env["FIXTURE_SHIPMENT_TOKEN"],self.env["FIXTURE_SENSOR_TOKEN"]=tokens + self.response=lambda path: {"tracking":"A42","delivered":True} if path.startswith("/shipments") else {"values":[3]} + self.proposals=[self.plan("shipment","xgen.http/read",shipment),self.plan("sensor","xgen.http/read",sensor)] + result=self.run_cli(extra=("--bearer-env-for","shipments=FIXTURE_SHIPMENT_TOKEN","--bearer-env-for","sensors=FIXTURE_SENSOR_TOKEN")) + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertEqual(len(self.calls),2) + for call,token in zip(self.calls,tokens): + self.assertTrue(call[1]=="Bearer "+token,"connection token routing mismatch") + self.assert_tokens_absent(tokens) + + def test_bearer_resume_pins_reference_but_allows_token_rotation_and_offline_replay(self): + args=self.install();variable="FIXTURE_REQUEST_TOKEN" + first=secrets.token_hex(24);second=secrets.token_hex(24) + self.env[variable]=first;self.proposals=[self.plan("read","xgen.http/read",args)] + paused=self.run_cli(allow=False,extra=("--bearer-env-for","shipments="+variable)) + self.assertEqual(paused.returncode,10,paused.stderr.decode()) + manifest=self.manifest();run_id=manifest["record"]["runId"] + self.assertEqual(manifest["record"]["httpBearerOverrides"],{"shipments":variable}) + resume=["resume",run_id,"--workspace",str(self.workspace),"--allow-dir",".","--allow-read"] + for extra in ([],["--bearer-env-for","shipments=FIXTURE_OTHER_TOKEN"]): + result=self.cli(*resume,*extra,check=False) + self.assertEqual(result.returncode,64,result.stderr.decode()) + self.assertIn(b"configuration=http_bearer_overrides",result.stderr) + self.assertEqual(self.calls,[]);self.assertEqual(len(self.requests),1) + self.env.pop(variable) + result=self.cli(*resume,"--bearer-env-for","shipments="+variable,check=False) + self.assertEqual(result.returncode,64,result.stderr.decode());self.assertEqual(self.calls,[]) + self.env[variable]=second + result=self.cli(*resume,"--bearer-env-for","shipments="+variable,check=False) + self.assertEqual(result.returncode,10,result.stderr.decode()) + self.assertEqual(len(self.calls),1) + self.assertTrue(self.calls[0][1]=="Bearer "+second,"rotated authorization mismatch") + result=self.cli(*resume,"--bearer-env-for","shipments="+variable,"--allow-remote-model-egress", "--base-url",f"http://127.0.0.1:{self.model.server_port}/v1",check=False) + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.env.pop(variable);calls=len(self.calls);requests=len(self.requests) + replay=self.cli("resume",run_id,check=False) + self.assertEqual(replay.returncode,0,replay.stderr.decode()) + self.assertEqual((len(self.calls),len(self.requests)),(calls,requests)) + self.assert_tokens_absent([first,second]) + + def test_invalid_bearer_override_never_falls_back_to_stored_token(self): + self.install() + for reference,value in (("shipments=FIXTURE_MISSING",None),("shipments=FIXTURE_EMPTY",""), + ("shipments=FIXTURE_INVALID","bad token"),("unknown=FIXTURE_UNKNOWN","synthetic"), + ("shipments=LANG","synthetic")): + variable=reference.split("=",1)[1] + if value is not None: self.env[variable]=value + result=self.run_cli(extra=("--bearer-env-for",reference)) + self.assertEqual(result.returncode,64,result.stderr.decode()) + self.assertEqual(self.calls,[]);self.assertEqual(self.requests,[]) + result=self.run_cli(extra=("--bearer-env-for","shipments=FIXTURE_EMPTY","--bearer-env-for","shipments=FIXTURE_INVALID")) + self.assertEqual(result.returncode,64,result.stderr.decode()) + self.assertFalse((self.state/"runs").exists()) + + def test_override_credential_echo_is_not_persisted(self): + args=self.install();token=secrets.token_hex(24);self.env["FIXTURE_REQUEST_TOKEN"]=token + self.response={"tracking":token,"delivered":True} + def report(payload): + self.assertEqual(self.context(payload)["toolOutputs"],[]) + return {"formatVersion":1,"kind":"completion_candidate","steps":[],"summary":"No verified API result."} + self.proposals=[self.plan("read","xgen.http/read",args),report] + result=self.run_cli(extra=("--bearer-env-for","shipments=FIXTURE_REQUEST_TOKEN")) + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertEqual(len(self.calls),1) + self.assert_tokens_absent([token]) + def test_http_failures_do_not_publish_unverified_body_or_follow_redirect(self): arguments=self.install() cases=[(302,b"redirect"),(500,b"internal"),(200,b"not-json"),(200,{"tracking":42,"delivered":True}),(200,b"x"*32769),(200,{"tracking":self.secret,"delivered":True}), (200,json.dumps({"tracking":self.secret,"delivered":True}).replace(self.secret, "".join("\\u%04x" % ord(c) for c in self.secret)).encode())]