diff --git a/MEMORY.md b/MEMORY.md index f2655d8..c0c090c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -165,3 +165,7 @@ - HTTP 확장1은 [PR #139](https://github.com/PlateerLab/xgen-cli/pull/139)로 main `0c29e15`에 머지했다. [Fast/Linux PR 검사](https://github.com/PlateerLab/xgen-cli/actions/runs/41084874587) 통과. - HTTP 확장2(ADR-0082, 구현·로컬 검증 완료): query 배열은 OpenAPI3 form/explode=true와 Swagger2 multi만 지원한다. scalar items와 배열 제약을 전체 schema로 검증하고 반복 query pair를 host에서 인코딩한다. 다른 직렬화·object items는 선택 제외/required 거절을 유지한다. 실행 profile은 한 상수 `xgen.http.read/v2`로 binding/profile에 고정한다. 이전 바이너리의 미완료 HTTP Run은 새 바이너리 resume에서 execution_profile drift로 요청0회 거절, 완료 Run은 네트워크 없이 재생됨을 실제 loopback fixture로 확인했다. 배열 wire·잘못된 값 0회 요청도 shipment/sensor에서 확인했으며 Rust781개(4 ignored)·pinned backend/컴파일 CLI 전체144개 검사(7 skipped)·Clippy·fmt·문서·workflow 검사를 통과했다. + +- 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 검증과 대조했으며 추가 재현된 결함은 없었다. diff --git a/README.md b/README.md index 199b63f..3753d55 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local-first general-purpose agent CLI and harness. -> 현재 상태: 프로토콜 v0.1 검증, dependency DAG와 Receipt-gated 재개 frontier를 갖춘 model-free WorkGraph, embedded SQLite schema 8 store, effect 실행·복구 조정기, 결정론적 Capability Registry·Router, I/O 없는 Permission Broker, secret-free invocation material 복구, Direct Executor와 Core-owned Execution Receipt 검증 경계, 장기 Run용 VerifiedRunIndex를 실행할 수 있습니다. Provider-neutral bounded AgentLoop는 비신뢰 `PlanProposal`을 검증해 다중 Step DAG와 reconstructable input sidecar를 원자 commit하고 재시작 뒤 한 frontier action씩 이어갑니다. Model call은 provider 호출 전에 보수적인 possible-send slot을 journal에 예약하며 accepted/rejected/Unknown을 재시작 뒤 복원하고, 불확정 호출을 자동 재시도하지 않습니다. 별도 `xgen-provider-openai` leaf adapter는 OpenAI-compatible Chat Completions의 단일 요청과 strict structured proposal을 이 lifecycle에 연결합니다. 내부 WorkGraph는 planned `ReadOnly`/`Idempotent`/`NonIdempotent`, Core Receipt v2의 bounded Artifact commitment와 event-anchored typed `ToolOutputRecord`를 지원하며, generation-checked `PlanningContext v3`는 Step의 plan journal 순서와 passed Receipt에 결합된 exact ToolOutput의 관찰 순서를 보존해 다음 model turn에 전달합니다. NonIdempotent durable output은 결과만 보존하고 Started 뒤 불확정 effect의 no-replay 의미는 유지합니다. Public `xgen run/resume`은 기존 exact `--allow-file` mode를 보존하면서, 명시적 `--allow-dir` mode에서 네 read Capability와 `write-atomic`·`apply-patch`로 workspace를 탐색·수정합니다. Host가 등록한 executable은 별도 승인 뒤 shell 없이 bounded cwd·argv·환경·timeout·출력 제한으로 실행하며 결과를 durable하게 보존하고 불확정 실행을 자동 반복하지 않습니다. Dynamic path/query/write/process material은 Run 전용 embedded `materials.sqlite3`에 digest-bound recipe로 저장되어 approval pause 뒤 다른 process에서도 복원됩니다. 모델 프로필은 일반 설정과 OS 보안 credential 저장소를 분리하고 catalog와 실제 strict-JSON inference를 모두 검증합니다. Bare `xgen`는 이 경계 위에서 Thinking 표시와 검증된 최종 응답을 제공하고 model/read/write/execute를 각각 묻는 가벼운 REPL을 시작합니다. 로컬 JSON OpenAPI 명세로 연결한 제한된 HTTP GET 조회는 지원합니다. 지원 범위는 [HTTP 조회 문서](docs/development/http-read-tools.md)를 따르며, HTTP 쓰기·범용 MCP 클라이언트·XGEN compatibility adapter는 후속 범위입니다. +> 현재 상태: 프로토콜 v0.1 검증, dependency DAG와 Receipt-gated 재개 frontier를 갖춘 model-free WorkGraph, embedded SQLite schema 8 store, effect 실행·복구 조정기, 결정론적 Capability Registry·Router, I/O 없는 Permission Broker, secret-free invocation material 복구, Direct Executor와 Core-owned Execution Receipt 검증 경계, 장기 Run용 VerifiedRunIndex를 실행할 수 있습니다. Provider-neutral bounded AgentLoop는 비신뢰 `PlanProposal`을 검증해 다중 Step DAG와 reconstructable input sidecar를 원자 commit하고 재시작 뒤 한 frontier action씩 이어갑니다. Model call은 provider 호출 전에 보수적인 possible-send slot을 journal에 예약하며 accepted/rejected/Unknown을 재시작 뒤 복원하고, 불확정 호출을 자동 재시도하지 않습니다. 별도 `xgen-provider-openai` leaf adapter는 OpenAI-compatible Chat Completions의 단일 요청과 strict structured proposal을 이 lifecycle에 연결합니다. 내부 WorkGraph는 planned `ReadOnly`/`Idempotent`/`NonIdempotent`, Core Receipt v2의 bounded Artifact commitment와 event-anchored typed `ToolOutputRecord`를 지원하며, generation-checked `PlanningContext v3`는 Step의 plan journal 순서와 passed Receipt에 결합된 exact ToolOutput의 관찰 순서를 보존해 다음 model turn에 전달합니다. NonIdempotent durable output은 결과만 보존하고 Started 뒤 불확정 effect의 no-replay 의미는 유지합니다. Public `xgen run/resume`은 기존 exact `--allow-file` mode를 보존하면서, 명시적 `--allow-dir` mode에서 네 read Capability와 `write-atomic`·`apply-patch`로 workspace를 탐색·수정합니다. Host가 등록한 executable은 별도 승인 뒤 shell 없이 bounded cwd·argv·환경·timeout·출력 제한으로 실행하며 결과를 durable하게 보존하고 불확정 실행을 자동 반복하지 않습니다. Dynamic path/query/write/process material은 Run 전용 embedded `materials.sqlite3`에 digest-bound recipe로 저장되어 approval pause 뒤 다른 process에서도 복원됩니다. 모델 프로필은 일반 설정과 OS 보안 credential 저장소를 분리하고 catalog와 실제 strict-JSON inference를 모두 검증합니다. Bare `xgen`는 이 경계 위에서 Thinking 표시와 검증된 최종 응답을 제공하고 model/read/write/execute를 각각 묻는 가벼운 REPL을 시작합니다. 로컬 JSON OpenAPI 명세로 연결한 제한된 HTTP GET·호스트 지정 POST 조회는 지원합니다. 지원 범위는 [HTTP 조회 문서](docs/development/http-read-tools.md)를 따르며, HTTP 쓰기·범용 MCP 클라이언트·XGEN compatibility adapter는 후속 범위입니다. ## 설치와 첫 모델 연결 diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index cd40342..685964f 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -1673,7 +1673,7 @@ fn continue_incomplete( ); } if discovery.has_connections() { - planning_constraints.push(PlanningConstraint::new("http.read-connections", "Host-approved GET connections are available through xgen.http/read. First describe the tool, check http_read.supported, use its exact contract_digest as contractDigest, and pass parameters.path and parameters.query. A successful HTTP Receipt and schema-valid body are required before claiming API results. Credentials are resolved only by the host.").map_err(|_| PublicRunError::Internal)?); + planning_constraints.push(PlanningConstraint::new("http.read-connections", "Host-approved read connections are available through xgen.http/read. First describe the tool, check http_read.supported, use its exact contract_digest as contractDigest, and pass parameters.path and parameters.query. For explicitly host-approved POST reads, also pass body according to http_read.request_body. A successful HTTP Receipt and schema-valid body are required before claiming API results. Credentials are resolved only by the host.").map_err(|_| PublicRunError::Internal)?); } if let Some(process) = process { planning_constraints.push( @@ -3142,7 +3142,7 @@ fn execution_profile_with_discovery( "material_provider":graph_discovery::PROVIDER, "material_recipe_domain":"xgen.cli.tool-discovery-recipe/v1", "host_policy_profile":"xgen.cli.host-tool-discovery/v1", - "http_read_profile": if connections.is_empty() { serde_json::Value::Null } else { serde_json::json!({"profile":crate::http_read::PROFILE,"get_only":true,"max_body_bytes":32768,"timeout_seconds":30,"redirects":false,"proxy":false}) }, + "http_read_profile": if connections.is_empty() { serde_json::Value::Null } else { serde_json::json!({"profile":crate::http_read::PROFILE,"host_selected_post":true,"max_body_bytes":32768,"timeout_seconds":30,"redirects":false,"proxy":false}) }, }); if connections.is_empty() { profile diff --git a/crates/xgen-cli/src/graph_discovery.rs b/crates/xgen-cli/src/graph_discovery.rs index dbafa37..280f055 100644 --- a/crates/xgen-cli/src/graph_discovery.rs +++ b/crates/xgen-cli/src/graph_discovery.rs @@ -19,7 +19,7 @@ pub(crate) const SEARCH: &str = "xgen.tools/search"; pub(crate) const DESCRIBE: &str = "xgen.tools/describe"; pub(crate) const VERSION: &str = "1.0.0"; pub(crate) fn version(operation: &str) -> &'static str { - if operation == DESCRIBE { + if matches!(operation, DESCRIBE | crate::http_read::READ) { "2.0.0" } else { VERSION @@ -205,7 +205,7 @@ impl DiscoveryCatalog { } pub(crate) fn execution_contract(&self, name: &str, tool: &str) -> Result { let output = crate::tools::invoke_offline( - &json!({"operation":"execution_contract","root":self.root.to_str().ok_or("invalid_state_home")?,"name":name,"tool":tool,"expected_digest":self.snapshots.get(name).ok_or("collection_not_found")?}), + &json!({"http_policy":self.connection(name).map(crate::http_read::Connection::policy),"operation":"execution_contract","root":self.root.to_str().ok_or("invalid_state_home")?,"name":name,"tool":tool,"expected_digest":self.snapshots.get(name).ok_or("collection_not_found")?}), )?; // Full execution contracts stay on the host; the model's 64 KiB limit // applies only to discovery observations, never to schema validation. @@ -338,6 +338,10 @@ impl DiscoveryAdapter { request["query"] = input["query"].clone(); request["top_k"] = input["topK"].clone(); } else { + request["http_policy"] = self + .catalog + .connection(name) + .map_or(Value::Null, crate::http_read::Connection::policy); request["tool"] = input["tool"].clone(); request["parameter_offset"] = input.get("parameterOffset").cloned().unwrap_or(json!(0)); } diff --git a/crates/xgen-cli/src/http_read.rs b/crates/xgen-cli/src/http_read.rs index f648379..9a991a9 100644 --- a/crates/xgen-cli/src/http_read.rs +++ b/crates/xgen-cli/src/http_read.rs @@ -1,4 +1,4 @@ -//! Host-approved HTTP GET connections. Credentials never enter graph or model material. +//! Host-approved HTTP read connections. Credentials never enter graph or model material. use std::collections::BTreeMap; use std::fs; use std::io::{Read as _, Write as _}; @@ -17,7 +17,7 @@ use xgen_runtime::{ use zeroize::Zeroizing; pub(crate) const READ: &str = "xgen.http/read"; -pub(crate) const PROFILE: &str = "xgen.http.read/v2"; +pub(crate) const PROFILE: &str = "xgen.http.read/v3"; pub(crate) const SCOPE: &str = "http.read"; pub(crate) type Connections = BTreeMap; const MAX_BODY: usize = 32 * 1024; @@ -29,6 +29,8 @@ pub(crate) struct Connection { format_version: u32, base_url: String, allow_get: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + read_operations: Vec, bearer_env: Option, credential_ref: Option, } @@ -36,7 +38,13 @@ impl Connection { fn validate(&self) -> Result<(), &'static str> { let url = Url::parse(&self.base_url).map_err(|_| "invalid_http_base_url")?; if self.format_version != 1 - || !self.allow_get + || (!self.allow_get && self.read_operations.is_empty()) + || self.read_operations.len() > 32 + || self + .read_operations + .iter() + .any(|s| s.is_empty() || s.len() > 128 || s.chars().any(char::is_control)) + || self.read_operations.windows(2).any(|p| p[0] >= p[1]) || self.base_url.len() > 1024 || !url.username().is_empty() || url.password().is_some() @@ -67,6 +75,9 @@ impl Connection { } Ok(()) } + 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 { Some(Zeroizing::new( @@ -102,9 +113,34 @@ fn connection_root() -> Result { Ok(state.join("http-connections")) } +fn validate_selection(name: &str, connection: &Connection) -> Result<(), &'static str> { + let snapshots = DiscoveryCatalog::discover().map_err(|()| "http_catalog_unavailable")?; + if !snapshots.contains_key(name) { + return Err("collection_not_found"); + } + let catalog_root = crate::run_layout::discover_state_root() + .map_err(|_| "invalid_state_home")? + .join("tool-collections"); + let result = crate::tools::invoke_offline( + &json!({"operation":"validate_read_operations", "root":catalog_root.to_str().ok_or("invalid_state_home")?, "name":name, "expected_digest":snapshots[name], "read_operations":connection.read_operations}), + )?; + if result["ok"] != true { + return Err("http_read_operation_invalid"); + } + if serde_json::to_vec(&connection) + .map_err(|_| "invalid_http_connection")? + .len() + > 4096 + { + return Err("invalid_http_connection"); + } + Ok(()) +} + pub(crate) fn connect( name: &str, base_url: String, + policy: (bool, Vec), bearer_env: Option, token_stdin: bool, bearer_prompt: bool, @@ -114,18 +150,19 @@ pub(crate) fn connect( { return Err("invalid_http_connection"); } + let (allow_get, mut read_operations) = policy; + read_operations.sort(); + read_operations.dedup(); let mut connection = Connection { format_version: 1, base_url, - allow_get: true, + allow_get, + read_operations, bearer_env, credential_ref: None, }; connection.validate()?; - let snapshots = DiscoveryCatalog::discover().map_err(|()| "http_catalog_unavailable")?; - if !snapshots.contains_key(name) { - return Err("collection_not_found"); - } + validate_selection(name, &connection)?; let root = connection_root()?; crate::run_layout::ensure_private_state_root(root.parent().ok_or("invalid_state_home")?) .map_err(|_| "invalid_state_home")?; @@ -194,7 +231,7 @@ pub(crate) fn connect( } saved?; Ok( - json!({"ok":true,"collection":name,"connected":true,"methods":["GET"],"authentication":if secret.is_some() {"os_secret_store"} else if connection.bearer_env.is_some() {"local_environment"} else {"none"}}), + json!({"ok":true,"collection":name,"connected":true,"policy":connection.policy(),"authentication":if secret.is_some() {"os_secret_store"} else if connection.bearer_env.is_some() {"local_environment"} else {"none"}}), ) } @@ -307,7 +344,9 @@ pub(crate) fn discover(snapshots: &graph_discovery::Snapshots) -> Result bool { - input.as_object().is_some_and(|o| o.len() == 4) + input + .as_object() + .is_some_and(|o| o.len() == 4 || (o.len() == 5 && o.contains_key("body"))) && input["collection"] .as_str() .is_some_and(|name| catalog.connection(name).is_some()) @@ -515,7 +554,8 @@ pub(crate) fn prepare( .build(schema) .map_err(|_| AdapterPrepareFailure::InvalidMaterial)?; } - if contract["method"] != "GET" { + validate_body(&contract, input).map_err(|()| AdapterPrepareFailure::InvalidMaterial)?; + if !matches!(contract["method"].as_str(), Some("GET" | "POST")) { return Err(AdapterPrepareFailure::UnsupportedProtocol); } let credential = connection @@ -539,9 +579,36 @@ struct PreparedRead { url: Url, credential: Option>, } +impl PreparedRead { + fn send(&self, agent: &ureq::Agent) -> Result, ureq::Error> { + if self.contract["method"] == "POST" { + let mut request = agent + .post(self.url.as_str()) + .header("Accept", "application/json"); + if let Some(secret) = &self.credential { + request = request.header("Authorization", format!("Bearer {}", secret.as_str())); + } + if let Some(body) = self.input.get("body") { + request + .header("Content-Type", "application/json") + .send(serde_jcs::to_vec(body).expect("validated JSON")) + } else { + request.send_empty() + } + } else { + let mut request = agent + .get(self.url.as_str()) + .header("Accept", "application/json"); + if let Some(secret) = &self.credential { + request = request.header("Authorization", format!("Bearer {}", secret.as_str())); + } + request.call() + } + } +} impl PreparedAdapterInvocation for PreparedRead { fn execute(self: Box) -> AdapterExecutionObservation { - let mut output = json!({"ok":false,"error":"http_request_failed","collection":self.input["collection"],"tool":self.input["tool"],"contractDigest":self.input["contractDigest"],"artifact_digest":self.catalog.snapshot(self.input["collection"].as_str().expect("validated collection")),"backend_version":"0.47.0","execution_enabled":true,"request":self.input,"method":"GET","target":self.url.as_str(),"status":null,"body":null}); + let mut output = json!({"ok":false,"error":"http_request_failed","collection":self.input["collection"],"tool":self.input["tool"],"contractDigest":self.input["contractDigest"],"artifact_digest":self.catalog.snapshot(self.input["collection"].as_str().expect("validated collection")),"backend_version":"0.47.0","execution_enabled":true,"request":self.input,"method":self.contract["method"],"target":self.url.as_str(),"status":null,"body":null}); let agent = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(30))) .max_redirects(0) @@ -551,13 +618,8 @@ impl PreparedAdapterInvocation for PreparedRead { .user_agent("xgen-http-read/1.0") .build() .new_agent(); - let mut request = agent - .get(self.url.as_str()) - .header("Accept", "application/json"); - if let Some(secret) = &self.credential { - request = request.header("Authorization", format!("Bearer {}", secret.as_str())); - } - if let Ok(mut response) = request.call() { + let response = self.send(&agent); + if let Ok(mut response) = response { let status = response.status().as_u16(); output["status"] = json!(status); let json_media = response @@ -650,13 +712,35 @@ fn response_valid(contract: &Value, status: u16, body: &Value) -> bool { }) } +fn validate_body(contract: &Value, input: &Value) -> Result<(), ()> { + match (contract.get("request_body"), input.get("body")) { + (Some(definition), Some(body)) if contract["method"] == "POST" => { + let schema = &definition["schema"]; + jsonschema::meta::options() + .validate(schema) + .map_err(|_| ())?; + jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .offline() + .build(schema) + .map_err(|_| ())? + .validate(body) + .map_err(|_| ()) + } + (Some(definition), None) if definition["required"] == false => Ok(()), + (None, None) => Ok(()), + _ => Err(()), + } +} + pub(crate) fn inspect(catalog: &DiscoveryCatalog, input: &Value, output: &Value) -> Result<(), ()> { let contract = descriptor(catalog, input).map_err(|_| ())?; + validate_body(&contract, input)?; let name = input["collection"].as_str().ok_or(())?; let connection = catalog.connection(name).ok_or(())?; let target = request_url(connection, &contract, input).map_err(|_| ())?; if output.as_object().is_none_or(|object| object.len() != 13) - || output["method"] != "GET" + || output["method"] != contract["method"] || output["target"] != target.as_str() || output["tool"] != input["tool"] || output["contractDigest"] != input["contractDigest"] @@ -875,6 +959,7 @@ mod tests { format_version: 1, base_url: "https://example.test/api".into(), allow_get: true, + read_operations: Vec::new(), bearer_env: None, credential_ref: None, } @@ -886,6 +971,27 @@ mod tests { {"name":"active","location":"query","required":false,"schema":{"type":"boolean"}} ],"responses":{"200":{"type":"object","required":["value"],"properties":{"value":{"type":"integer","minimum":0}},"additionalProperties":false}}}) } + #[test] + fn post_body_uses_the_entire_original_schema_and_presence_rules() { + let mut contract = json!({"method":"POST","request_body":{"required":true,"schema":{ + "type":"object","required":["ids"],"properties":{"ids":{"type":"array","items":{"type":"integer","minimum":1},"minItems":1,"uniqueItems":true}},"additionalProperties":false}}}); + assert!(validate_body(&contract, &json!({"body":{"ids":[1,2]}})).is_ok()); + for input in [ + json!({}), + json!({"body":null}), + json!({"body":{"ids":[0]}}), + json!({"body":{"ids":[1,1]}}), + json!({"body":{"ids":[1],"extra":true}}), + ] { + assert!(validate_body(&contract, &input).is_err()); + } + contract["request_body"]["required"] = json!(false); + assert!(validate_body(&contract, &json!({})).is_ok()); + contract["method"] = json!("GET"); + assert!(validate_body(&contract, &json!({"body":{"ids":[1]}})).is_err()); + assert!(validate_body(&json!({"method":"POST"}), &json!({"body":{}})).is_err()); + } + #[test] fn url_uses_fixed_origin_and_typed_encoded_parameters() { let input = diff --git a/crates/xgen-cli/src/tool_graph_worker.py b/crates/xgen-cli/src/tool_graph_worker.py index 1a7c8c7..458b8f0 100644 --- a/crates/xgen-cli/src/tool_graph_worker.py +++ b/crates/xgen-cli/src/tool_graph_worker.py @@ -319,12 +319,26 @@ def dispatch(request): envelope = load(root, name) if request.get("expected_digest") is not None and request["expected_digest"] != envelope["artifact_digest"]: raise WorkerError("collection_snapshot_changed") + if operation == "validate_read_operations": + sources = envelope["artifact"].get("metadata", {}).get("xgen_http_sources", {}) + for selected in request["read_operations"]: + matches = [] + for source in sources.values(): + for path_item in source.get("paths", {}).values(): + path_item = dereference(path_item, source, recursive=False) + for method in ("get", "post", "put", "patch", "delete", "head", "options", "trace"): + value = path_item.get(method, {}) + if isinstance(value, dict) and value.get("operationId") == selected: + matches.append(method) + if len(matches) != 1 or matches[0] not in ("get", "post"): + raise WorkerError("http_read_operation_invalid") + return {"ok": True} graph = graph_from_artifact(envelope["artifact"]) if operation in ("describe", "describe_view", "execution_contract"): tool = graph.tools.get(request["tool"]) if tool is None: raise WorkerError("tool_not_found") - contract = http_read_contract(envelope["artifact"], tool.to_dict()) + contract = http_read_contract(envelope["artifact"], tool.to_dict(), request.get("http_policy")) if operation == "execution_contract": return {"ok": True, **summarize(envelope), "tool_name": tool.name, "http_read": contract, "contract_kind": "host_http_contract"} @@ -449,6 +463,9 @@ def describe_view(envelope, tool, http, offset): "bearer_required": contract["bearer_required"], "response_schema_digests": {status: digest(schema) for status, schema in list(contract["responses"].items())[:32] if len(status) <= 3}, "response_schema_count": len(contract["responses"]), "response_schemas_complete": False}) + if http["supported"] and "request_body" in http["contract"]: + body = http["contract"]["request_body"] + http_view["request_body"] = {"required": body["required"], **schema_view(body["schema"])} if http["supported"] and http["contract"].get("excluded_parameters"): excluded = http["contract"]["excluded_parameters"] excluded_rows = [] @@ -519,7 +536,7 @@ def http_read_parameter(parameter, source): "required": bool(parameter.get("required")) or location == "path", "schema": schema} -def http_read_contract(artifact, tool): +def http_read_contract(artifact, tool, policy=None): # The normalized graph is discovery data. Invoke only against retained source schemas. metadata = tool.get("metadata", {}) source = artifact.get("metadata", {}).get("xgen_http_sources", {}).get(metadata.get("source_url")) @@ -527,17 +544,35 @@ def http_read_contract(artifact, tool): if not isinstance(source, dict): raise WorkerError("http_original_source_unavailable") method, path = metadata.get("method"), metadata.get("path") - if method != "get": + policy = policy or {"allow_get": True, "read_operations": []} + if method not in ("get", "post"): raise WorkerError("http_method_not_read_approved") item = dereference(source["paths"][path], source, recursive=False) operation = item[method] if not isinstance(operation, dict): raise WorkerError("http_original_contract_invalid") + operation_id = operation.get("operationId") + selected = operation_id in policy.get("read_operations", []) + if not ((method == "get" and policy.get("allow_get")) or selected): + raise WorkerError("http_method_not_read_approved") + body = None + if method == "post" and "requestBody" in operation: + body_definition = dereference(operation["requestBody"], source) + if not isinstance(body_definition, dict) or type(body_definition.get("required", False)) is not bool: + raise WorkerError("http_original_contract_invalid") + media = body_definition.get("content", {}) + if set(media) != {"application/json"} or not isinstance(media["application/json"], dict): + raise WorkerError("http_request_media_unsupported") + schema = media["application/json"].get("schema") + if not isinstance(schema, (dict, bool)): + raise WorkerError("http_request_schema_missing") + check_http_schema(schema) + body = {"required": body_definition.get("required", False), "schema": schema} parameter_groups = [item.get("parameters", []), operation.get("parameters", [])] if any(not isinstance(group, list) or any(not isinstance(p, dict) for p in group) for group in parameter_groups): raise WorkerError("http_original_contract_invalid") - if operation.get("requestBody") or any(p.get("in") == "body" for p in parameter_groups[1]): + if (method == "get" and operation.get("requestBody")) or any(p.get("in") == "body" for group in parameter_groups for p in group): raise WorkerError("http_get_body_unsupported") # Operation overrides are allowed across groups; duplicates within one group are invalid. merged = {} @@ -597,7 +632,9 @@ def http_read_contract(artifact, tool): raise WorkerError("http_security_unsupported") for schema in responses.values(): check_http_schema(schema) - contract = {"format_version":1,"method":"GET","path":path,"base_path":source.get("basePath", ""),"parameters":included,"responses":responses,"bearer_required":bool(security) and not any(not requirement for requirement in security),"source_digest":digest(source)} + contract = {"format_version":1,"method":method.upper(),"path":path,"base_path":source.get("basePath", ""),"parameters":included,"responses":responses,"bearer_required":bool(security) and not any(not requirement for requirement in security),"source_digest":digest(source)} + if body is not None: + contract["request_body"] = body if excluded: contract["excluded_parameters"] = excluded if len(canonical(contract)) > 8 * 1024 * 1024: diff --git a/crates/xgen-cli/src/tools.rs b/crates/xgen-cli/src/tools.rs index 8b0490c..402279d 100644 --- a/crates/xgen-cli/src/tools.rs +++ b/crates/xgen-cli/src/tools.rs @@ -30,15 +30,18 @@ pub enum ToolsCommand { #[arg(long, required = true, num_args = 1..)] source: Vec, }, - /// Connect an imported collection to a host-approved GET-only API base URL. + /// Connect an imported collection to a host-approved read API base URL. Connect { #[arg(long)] name: String, #[arg(long)] base_url: String, /// Confirm that this system's GET operations are non-mutating read operations. - #[arg(long, required = true)] + #[arg(long)] allow_get: bool, + /// Declare an exact `OpenAPI` operationId to be a non-mutating read. + #[arg(long)] + read_operation: Vec, /// Read the bearer token from a local environment variable at execution time. #[arg(long)] bearer_env: Option, @@ -105,12 +108,20 @@ pub fn run(command: ToolsCommand) -> ExitCode { name, base_url, allow_get, + read_operation, bearer_env, bearer, token_stdin, } => { - if allow_get { - crate::http_read::connect(&name, base_url, bearer_env, token_stdin, bearer) + if allow_get || !read_operation.is_empty() { + crate::http_read::connect( + &name, + base_url, + (allow_get, read_operation), + bearer_env, + token_stdin, + bearer, + ) } else { Err("http_get_consent_required") } @@ -217,6 +228,21 @@ fn request(command: ToolsCommand) -> Result { crate::run_layout::ensure_private_state_root(&state_root).map_err(|_| "invalid_state_home")?; let root = state_root.join("tool-collections"); value["root"] = json!(root.to_str().ok_or("invalid_state_home")?); + if matches!( + value["operation"].as_str(), + Some("describe" | "describe_view") + ) { + let snapshots = crate::graph_discovery::DiscoveryCatalog::discover() + .map_err(|()| "http_catalog_unavailable")?; + let connections = + crate::http_read::discover(&snapshots).map_err(|()| "http_connection_unavailable")?; + if let Some(connection) = value["name"] + .as_str() + .and_then(|name| connections.get(name)) + { + value["http_policy"] = connection.policy(); + } + } Ok(value) } diff --git a/docs/adr/0083-host-selected-post-reads.md b/docs/adr/0083-host-selected-post-reads.md new file mode 100644 index 0000000..255f66c --- /dev/null +++ b/docs/adr/0083-host-selected-post-reads.md @@ -0,0 +1,45 @@ +# ADR-0083: 호스트가 지정한 operation의 POST 조회 + +- 날짜: 2026-10-31 +- 상태: 채택, 구현·로컬 검증 완료 +- 범위: 일반 OpenAPI 연결 기능. 외부 통합의 operation 수나 업무 효과를 엔진이 판단하지 않는다. + +## 결정 + +`tools connect --read-operation OPERATION_ID`를 반복해 조회 operation을 지정한다. +`--allow-get`은 기존의 GET 전체 지정이고, 둘 중 하나가 필요하다. 선택 목록은 정렬·중복 제거하며 +최대32개, 이름128 bytes, 연결 파일4096 bytes를 넘으면 저장하지 않는다. + +원본 source에서 ID가 유일하고 method가 GET/POST인 것만 지정할 수 있다. PUT/PATCH/DELETE는 +지정해도 거절한다. 설명이나 HTTP method만으로 조회 효과를 추정하지 않는다. 사용자가 조회로 +지정한 operation은 기존 ReadOnly 권한·Receipt·검증 경로를 통과한다. + +POST 본문은 OpenAPI requestBody의 application/json 단일 미디어 형식만 지원한다. 내부 ref를 +해제한 원본 schema, required를 보존하고 native host가 전체 schema로 검증한다. Swagger2 body, +여러 미디어 형식은 지원하지 않는다. 본문 없는 POST는 원본에 requestBody가 없을 때만 가능하다. +정의하지 않은 본문, 필수 본문 누락, schema 위반은 HTTP 전에 거절한다. 입력 전체8KiB 한도는 유지한다. + +`xgen.http/read@2.0.0`에 선택 입력 `body`를 추가한다. method는 모델 입력이 아니라 원본 계약에서 +결정하고 결과에서도 검증한다. 모델 describe에 본문 required와 bounded schema view를 제공한다. +전체 schema는 호스트에만 있으며 큰 schema의 view 생략을 검증 생략으로 취급하지 않는다. + +연결의 지정 목록은 manifest·execution profile에 포함된다. 목록 변경은 기존 `http_connections` +drift 규칙으로 재개를 막는다. 새 실행 profile은 `xgen.http.read/v3`이다. 이전 미완료 Run은 새 +profile로 계속할 수 없고, 완료 Run의 offline replay는 유지한다. 인증 참조·고정 origin·redirect 금지· +응답 schema 검증·credential echo 차단은 기존과 같다. + +## 검증 + +설계 assets와 별도 shipment 원본의 POST 승인·본문 계약을 검증한다. 실제 loopback API/컴파일 CLI로 +shipment와 measurement의 POST JSON wire·원본 body 제약·미지정 POST 0회 호출·목록 drift를 검증한다. +다른 method와 알 수 없는 ID는 연결 저장 전에 거절하고, 이전 GET profile의 미완료 거절·완료 replay도 +기존 fixture로 검증한다. 결정적 모델 endpoint를 쓰며 실제 LLM 품질이나 외부 API coverage는 측정하지 않는다. + +## 근거 + +- [OpenAPI Operation Object](https://spec.openapis.org/oas/v3.1.0.html#operation-object) +- [OpenAPI Request Body Object](https://spec.openapis.org/oas/v3.1.0.html#request-body-object) +- ADR-0081, ADR-0082의 파라미터 정책을 POST query에도 동일하게 적용한다. +- 승인 있는 쓰기와 Idempotency-Key 정책은 별도 설계로 남긴다. + +Rust 전체782개(4 ignored), pinned backend+컴파일 CLI의 Python148개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사를 통과했다. diff --git a/docs/development/http-read-tools.md b/docs/development/http-read-tools.md index 0e64971..cc6c9a9 100644 --- a/docs/development/http-read-tools.md +++ b/docs/development/http-read-tools.md @@ -37,9 +37,25 @@ xgen tools remove --name my-system # collection, 연결, 저장된 토큰 - 연결 저장소가 사용자 전용이 아니거나, 연결 파일이 symlink거나, 저장소를 읽을 수 없으면(권한 오류를 "연결 없음"으로 보지 않는다) 아무것도 지우지 않고 실패한다. - 연결을 바꾸면 그 연결로 시작한 Run은 재개 시 기존처럼 거절된다. +## 호스트가 지정한 POST 조회 + +```bash +xgen tools connect --name my-system --base-url https://api.example.com \ + --allow-get --read-operation searchRecords --read-operation inspectRecords --bearer-env API_TOKEN +``` + +`--read-operation`은 원본 OpenAPI operationId를 반복 지정한다. `--allow-get` 없이 선택한 GET/POST만 +허용할 수도 있다. 지정하지 않은 POST, 유일하지 않거나 없는 ID, PUT/PATCH/DELETE는 거절한다. +이는 호스트의 조회 선언이며 서버의 실제 부작용을 xgen이 추론하거나 보장하는 기능이 아니다. + +POST는 OpenAPI `requestBody`의 단일 `application/json`과 원본 schema를 사용한다. read 입력의 +선택 필드 `body`에 JSON을 넣고 required·전체 schema를 검증한다. 입력 전체8KiB 한도는 유지한다. +Swagger2 body와 여러 미디어 형식은 아직 지원하지 않는다. 목록은 연결·manifest·execution profile에 +고정되어 바꾸면 기존 Run 재개가 거절된다. [ADR-0083](../adr/0083-host-selected-post-reads.md). + ## 실행 계약 -agent loop에는 연결이 있을 때 `xgen.http/read@1.0.0`이 추가된다. 외부 도구마다 실행용 Definition을 자동 등록하는 기능과는 구분한다. 검색과 describe는 후보를 제공하고, read 프록시는 exact collection·tool·원본 HTTP 계약 digest·입력을 기존 material/policy/executor/verifier 경로에 연결한다. +agent loop에는 연결이 있을 때 `xgen.http/read@2.0.0`이 추가된다. 외부 도구마다 실행용 Definition을 자동 등록하는 기능과는 구분한다. 검색과 describe는 후보를 제공하고, read 프록시는 exact collection·tool·원본 HTTP 계약 digest·입력을 기존 material/policy/executor/verifier 경로에 연결한다. 입력은 `collection`, `tool`, describe의 `http_read.contract_digest`를 복사한 `contractDigest`, `parameters: {path: {...}, query: {...}}`다. 모델은 URL·인증·헤더·HTTP method를 지정하지 못한다. collection snapshot과 연결 설정은 manifest와 execution profile에 고정된다. 새 연결은 이전 Run에 추가하지 않고, 기존 연결이 바뀌면 재개를 거절한다. 인증 값 자체는 manifest에 넣지 않으며 만료된 토큰의 교체는 같은 호스트 인증 참조를 통해 가능하다. @@ -49,7 +65,7 @@ agent loop에는 연결이 있을 때 `xgen.http/read@1.0.0`이 추가된다. ## 지원 범위와 한도 -- 새로 가져온 **로컬 JSON 문서만으로 구성한 collection**의 GET. OpenAPI 3.1/3.0 및 Swagger 2의 아래 부분집합을 지원한다. +- 새로 가져온 **로컬 JSON 문서만으로 구성한 collection**의 GET과 호스트 지정 POST 조회. OpenAPI 3.1/3.0 및 Swagger 2의 아래 부분집합을 지원한다. - 지원 밖인 선택 파라미터는 operation을 막지 않고 실행 입력에서 제외한다. 모델용 describe의 `http_read.excluded_parameters`에 이름 preview·위치·고정 이유를 표시하고 count/digest/complete를 함께 준다. 제외한 이름을 입력에 넣으면 HTTP 호출 전에 거절한다. required와 path 파라미터가 diff --git a/docs/development/tool-integration-todo.md b/docs/development/tool-integration-todo.md index a7ccb19..4abf72e 100644 --- a/docs/development/tool-integration-todo.md +++ b/docs/development/tool-integration-todo.md @@ -141,7 +141,7 @@ [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). -3. **이후:** 연결별 반복 `--read-operation`으로 호스트가 지정한 POST 조회, JSON body 원본 schema 검증, +3. **구현·로컬 검증 완료:** 연결별 반복 `--read-operation`으로 호스트가 지정한 POST 조회, JSON body 원본 schema 검증, 지정 목록의 연결·execution profile 고정과 drift 거절. 지정하지 않은 POST는 거절한다. 4. **이후:** run/resume의 `--bearer-env-for CONNECTION=ENV_NAME`으로 저장된 연결을 바꾸지 않고 요청별 bearer를 주입한다. 값은 manifest·Receipt·모델에 남기지 않는다. 참조 binding·resume 누락 시 diff --git a/protocol/fixtures/v1alpha1/valid/capability-definition.http-read.json b/protocol/fixtures/v1alpha1/valid/capability-definition.http-read.json index fb36b05..629f73f 100644 --- a/protocol/fixtures/v1alpha1/valid/capability-definition.http-read.json +++ b/protocol/fixtures/v1alpha1/valid/capability-definition.http-read.json @@ -3,14 +3,14 @@ "kind": "CapabilityDefinition", "metadata": { "id": "xgen.http/read", - "contractVersion": "1.0.0", + "contractVersion": "2.0.0", "displayName": "Read a connected HTTP API", "labels": { "xgeny.io/pack": "core-os" } }, "spec": { - "summary": "Execute one host-approved GET operation from an exact retained OpenAPI contract and connected collection. Credentials and target origin are host-owned. Use the http_read contract digest from describe; results require successful HTTP status and response schema verification.", + "summary": "Execute one host-approved GET or explicitly host-selected POST read operation from an exact retained OpenAPI contract and connected collection. Credentials and target origin are host-owned. Use the http_read contract digest from describe; results require successful HTTP status and response schema verification.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -50,7 +50,8 @@ } }, "additionalProperties": false - } + }, + "body": {} }, "additionalProperties": false }, @@ -128,7 +129,8 @@ } }, "additionalProperties": false - } + }, + "body": {} }, "additionalProperties": false }, @@ -142,7 +144,10 @@ "pattern": "^[a-f0-9]{64}$" }, "method": { - "const": "GET" + "enum": [ + "GET", + "POST" + ] }, "target": { "type": "string", diff --git a/scripts/tests/test_http_read_tools.py b/scripts/tests/test_http_read_tools.py index 85b1285..990a47e 100644 --- a/scripts/tests/test_http_read_tools.py +++ b/scripts/tests/test_http_read_tools.py @@ -197,6 +197,23 @@ def test_other_array_serializations_and_object_items_follow_optional_required_po self.assertEqual(result["contract"]["excluded_parameters"][0]["reason"],"http_parameter_serialization_unsupported") +class PostReadContractTests(unittest.TestCase): + def test_only_selected_post_has_original_json_body_contract(self): + for source,path in ((asset_spec(),"/assets"),(held_out_spec("shipments"),"/shipments/{tracking}")): + operation=source["paths"][path].pop("get") + source["paths"][path]["post"]=operation + operation["requestBody"]={"required":True,"content":{"application/json":{"schema":{"type":"object","required":["limit"],"properties":{"limit":{"type":"integer","minimum":1}},"additionalProperties":False}}}} + artifact={"metadata":{"xgen_http_sources":{"fixture":source}}} + tool={"metadata":{"source_url":"fixture","path":path,"method":"post"}} + self.assertFalse(WORKER.http_read_contract(artifact,tool)["supported"]) + policy={"allow_get":False,"read_operations":[operation["operationId"]]} + result=WORKER.http_read_contract(artifact,tool,policy) + self.assertTrue(result["supported"],result) + self.assertEqual(result["contract"]["request_body"]["schema"],operation["requestBody"]["content"]["application/json"]["schema"]) + operation["requestBody"]["content"]={"application/xml":{"schema":{}}} + self.assertFalse(WORKER.http_read_contract(artifact,tool,policy)["supported"]) + + @unittest.skipUnless(importlib.util.find_spec("graph_tool_call"),"run with pinned graph-tool-call environment") class ContractTests(unittest.TestCase): setUp = base.WorkerTests.setUp @@ -251,6 +268,7 @@ def setUp(self): self.secret=secrets.token_hex(24) self.env=dict(os.environ,XGEN_STATE_HOME=str(self.state),XGEN_FIXTURE_BEARER=self.secret) self.env.pop("XGEN_OPENAI_API_KEY",None) + self.bodies=[] self.calls=[] self.requests=[] self.response={"tracking":"A42","delivered":True} @@ -262,6 +280,9 @@ def setUp(self): test=self class ApiHandler(http.server.BaseHTTPRequestHandler): def log_message(self,*args): pass + def do_POST(self): + test.bodies.append(json.loads(self.rfile.read(int(self.headers.get("Content-Length",0))) or b"null")) + self.do_GET() def do_GET(self): test.calls.append((self.path,self.headers.get("Authorization"))) test.accepted.set() @@ -316,7 +337,7 @@ def install(self,name="shipments",spec=None): @staticmethod def plan(key,capability,args): - return {"formatVersion":1,"kind":"plan","summary":"","steps":[{"key":key,"objective":"Read connected API","dependsOn":[],"capability":{"capabilityId":capability,"contractVersion":"2.0.0" if capability=="xgen.tools/describe" else "1.0.0"},"arguments":args}]} + return {"formatVersion":1,"kind":"plan","summary":"","steps":[{"key":key,"objective":"Read connected API","dependsOn":[],"capability":{"capabilityId":capability,"contractVersion":"2.0.0" if capability in ("xgen.tools/describe", "xgen.http/read") else "1.0.0"},"arguments":args}]} @staticmethod def context(payload): @@ -438,6 +459,7 @@ def test_repeated_query_pairs_and_invalid_array_inputs_on_independent_sources(se @unittest.skipUnless(os.environ.get("XGEN_HTTP_V1_TEST_BINARY"),"previous HTTP profile binary opt-in") def test_previous_http_profile_does_not_resume_under_new_wire_rules(self): arguments=self.install();self.proposals=[self.plan("read","xgen.http/read",arguments)] + self.proposals[0]["steps"][0]["capability"]["contractVersion"]="1.0.0" paused=self.run_cli(allow=False,binary=os.environ["XGEN_HTTP_V1_TEST_BINARY"]) self.assertEqual(paused.returncode,10,paused.stderr.decode()) run_id=self.manifest()["record"]["runId"] @@ -451,6 +473,7 @@ def test_previous_http_profile_does_not_resume_under_new_wire_rules(self): @unittest.skipUnless(os.environ.get("XGEN_HTTP_V1_TEST_BINARY"),"previous HTTP profile binary opt-in") def test_completed_previous_http_profile_replays_without_network(self): arguments=self.install();self.proposals=[self.plan("read","xgen.http/read",arguments)] + self.proposals[0]["steps"][0]["capability"]["contractVersion"]="1.0.0" completed=self.run_cli(binary=os.environ["XGEN_HTTP_V1_TEST_BINARY"]) self.assertEqual(completed.returncode,0,completed.stderr.decode()) calls=len(self.calls);requests=len(self.requests) @@ -459,6 +482,83 @@ def test_completed_previous_http_profile_replays_without_network(self): self.assertEqual(len(self.calls),calls) self.assertEqual(len(self.requests),requests) + def install_post(self, name="shipments", approve=True): + source=held_out_spec(name) + if name=="shipments": + item=source["paths"]["/shipments/{tracking}"] + else: + source={"openapi":"3.1.0","info":{"title":"Measurements","version":"1"},"paths":{ + "/sensors/{sensorId}":{"get":{"operationId":"readSensor","parameters":[{"name":"sensorId","in":"path","required":True,"schema":{"type":"integer","minimum":1}}], + "responses":{"200":{"description":"Values","content":{"application/json":{"schema":{"type":"object","required":["values"],"properties":{"values":{"type":"array","items":{"type":"number"}}},"additionalProperties":False}}}}}}}}} + item=source["paths"]["/sensors/{sensorId}"] + operation=item.pop("get") + operation["requestBody"]={"required":True,"content":{"application/json":{"schema":{ + "type":"object","required":["limit"],"properties":{"limit":{"type":"integer","minimum":1,"maximum":5}},"additionalProperties":False}}}} + item["post"]=operation + target=operation["operationId"] + path=self.directory/(name+".json");path.write_text(json.dumps(source)) + self.cli("tools","import","--name",name,"--source",str(path)) + args=["tools","connect","--name",name,"--base-url",f"http://127.0.0.1:{self.api.server_port}","--allow-get"] + if name=="shipments": args += ["--bearer-env","XGEN_FIXTURE_BEARER"] + if approve: args += ["--read-operation",target] + self.cli(*args) + result=json.loads(self.cli("tools","describe","--name",name,target).stdout) + return {"collection":name,"tool":target,"contractDigest":result["http_read"].get("contract_digest","0"*64), + "parameters":{"path":{"tracking":"A42"} if name=="shipments" else {"sensorId":7},"query":{}},"body":{"limit":2}},result + + def test_host_selected_post_body_and_unapproved_operation(self): + for name in ("shipments","sensors"): + with self.subTest(name=name): + args,result=self.install_post(name) + self.assertTrue(result["http_read"]["supported"],result) + self.assertEqual(result["http_read"]["contract"]["method"],"POST") + view=json.loads(self.cli("tools","describe","--name",name,args["tool"],"--model-view").stdout) + self.assertTrue(view["http_read"]["request_body"]["required"]) + self.response={"tracking":"A42","delivered":True} if name=="shipments" else {"values":[1.5]} + self.requests.clear();self.calls.clear();self.bodies.clear() + self.proposals=[self.plan("post","xgen.http/read",args)] + completed=self.run_cli() + self.assertEqual(completed.returncode,0,completed.stderr.decode()) + self.assertEqual(self.bodies,[{"limit":2}]) + for body in ({"limit":0},{"limit":2,"extra":True},None): + bad=copy.deepcopy(args);bad["body"]=body + self.requests.clear();self.calls.clear();self.bodies.clear() + self.proposals=[self.plan("bad","xgen.http/read",bad)] + self.run_cli() + self.assertEqual(self.calls,[]) + args,result=self.install_post("unapproved",approve=False) + self.assertFalse(result["http_read"]["supported"]) + self.requests.clear();self.calls.clear();self.proposals=[self.plan("bad","xgen.http/read",args)] + self.run_cli();self.assertEqual(self.calls,[]) + + def test_connection_rejects_unknown_and_mutating_operation_selections(self): + source=asset_spec() + source["paths"]["/assets"]["delete"]={"operationId":"removeAssets","responses":{"204":{"description":"Deleted"}}} + path=self.directory/"selection.json";path.write_text(json.dumps(source)) + self.cli("tools","import","--name","selection","--source",str(path)) + for selected in ("missingOperation","removeAssets"): + result=self.cli("tools","connect","--name","selection","--base-url",f"http://127.0.0.1:{self.api.server_port}","--read-operation",selected,check=False) + self.assertNotEqual(result.returncode,0) + self.assertFalse((self.state/"http-connections"/"selection.json").exists()) + self.cli("tools","connect","--name","selection","--base-url",f"http://127.0.0.1:{self.api.server_port}","--read-operation","listAssets") + saved=json.loads((self.state/"http-connections"/"selection.json").read_text()) + self.assertFalse(saved["allow_get"]) + self.assertEqual(saved["read_operations"],["listAssets"]) + + def test_selected_post_operation_drift_rejects_resume(self): + args,_=self.install_post() + self.proposals=[self.plan("post","xgen.http/read",args)] + paused=self.run_cli(allow=False) + self.assertEqual(paused.returncode,10,paused.stderr.decode()) + path=self.state/"http-connections"/"shipments.json" + connection=json.loads(path.read_text());connection["read_operations"]=[] + path.write_text(json.dumps(connection));path.chmod(0o600) + result=self.cli("resume",self.manifest()["record"]["runId"],"--workspace",str(self.workspace),"--allow-dir",".", + "--base-url",f"http://127.0.0.1:{self.model.server_port}/v1","--allow-read",check=False) + self.assertEqual(result.returncode,64,result.stderr.decode()) + self.assertIn(b"configuration=http_connections",result.stderr) + self.assertEqual(self.calls,[]);self.assertEqual(len(self.requests),1) + 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())]