diff --git a/MEMORY.md b/MEMORY.md index 0d89d82..05ef21f 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -158,3 +158,7 @@ - 상태 폴더 권한 진단(2026-10-31, ADR-0080, 구현·로컬 검증 완료): Unix mode의 group/other 비트 거절은 유지하고, root·runs·개별 Run 폴더 오류를 `state_directory_permissions`/exit64로 전달한다. CLI run/resume/usage/recover·REPL에서 문제 경로·현재 mode·`chmod go-rwx -- PATH` 안내를 표시하며 자동 chmod·재귀 수정은 없다. 명시적 로컬 진단만 경로를 escape해 표시하고 일반 Debug·오류 Display·journal·모델·progress에서는 가린다. 설계 root0775/runs0755와 별도 run0770·symlink·space·개행/ESC·비UTF8 경로를 검증했다. 사용자가 직접0700으로 고치면 권한 검사 다음 단계로 진행하며 fixture DB는 생성되지 않는다. 전체 Rust 회귀778개(4 ignored)·Clippy·fmt·protocol·공개 문서·배포 workflow 검사를 통과했다. 소유자는 xgen CLI이며 외부 도구 변경은 없다. - 상태 폴더 진단 변경 추적: [PR #137](https://github.com/PlateerLab/xgen-cli/pull/137), 코드 `7edf540`, [전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41077456833). 실제 외부 모델·다른 OS의 terminal 화면 실측은 하지 않았다. - 상태 폴더 진단의 [Linux PR 검사](https://github.com/PlateerLab/xgen-cli/actions/runs/41077575969)가 통과했다. 플랫폼 전체 결과는 위 전체 CI run을 참조한다. + +- HTTP 조회 확장 요청(2026-10-31, 사용자 승인): 범용 기능으로 선택 unsupported 파라미터 단위 제외 → query 배열(form/explode, Swagger multi) → operation별 호스트 지정 POST 조회 → Run별 bearer env 주입 순서다. 1번은 단독 PR로 먼저 제공한다. 승인 있는 쓰기는 대회 후 별도 설계다. 상세 정본은 [통합 TODO](docs/development/tool-integration-todo.md#http-조회-확장-요청-2026-10-31). 외부 커머스 API는 통합 사례로만 쓰고 설계 기준에 넣지 않는다. 생성기·endpoint·인증·비교기는 외부 통합 담당자의 책임이며 실제 실행 가능 과업 수는 미검증이다. +- 선택 HTTP 파라미터(ADR-0081, 구현·로컬 검증): override 병합 뒤 지원 밖 선택 항목만 제외하고 name/location/reason을 contract digest와 모델 describe에 포함한다. required/path·보안/응답 오류는 계속 operation을 거절한다. 기존 host의 closed parameter 검사로 제외 입력은 요청 전에 거절된다. assets/calendar 설계와 shipment/sensor 실제 loopback import→describe→GET·악의적 제외 입력 0회 요청을 확인했다. 기존 HTTP 실패 테스트의 즉시 종료 기대를 ADR-0052의 실패 관찰 후 보고 계약에 맞췄으며 엔진 실패 의미는 바꾸지 않았다. 자체 과제 평가는 [draft #138](https://github.com/PlateerLab/xgen-cli/pull/138)에 보존했고 모델 인증 대기는 계속된다. +- 선택 파라미터 검증 결과: Rust778개(4 ignored), system Python venv+graph-tool-call0.47.0+컴파일 CLI의 전체139개 검사(7 skipped), HTTP 관련18개 전부 통과했다. Clippy·fmt·protocol·문서·workflow 검사도 통과했다. UV standalone Python의 pidfd_open 부재는 기존 평가 process-cleanup 도구의 환경 제약으로 분리했고 system Python에서 전체 검증했다. diff --git a/README.md b/README.md index 9f3da8b..199b63f 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을 시작합니다. Network tool과 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 조회는 지원합니다. 지원 범위는 [HTTP 조회 문서](docs/development/http-read-tools.md)를 따르며, HTTP 쓰기·범용 MCP 클라이언트·XGEN compatibility adapter는 후속 범위입니다. ## 설치와 첫 모델 연결 diff --git a/crates/xgen-cli/src/tool_graph_worker.py b/crates/xgen-cli/src/tool_graph_worker.py index 47ce023..ddefce3 100644 --- a/crates/xgen-cli/src/tool_graph_worker.py +++ b/crates/xgen-cli/src/tool_graph_worker.py @@ -351,11 +351,11 @@ def dispatch(request): raise WorkerError("unknown_operation") -def dereference(value, document, stack=(), depth=0): +def dereference(value, document, stack=(), depth=0, *, recursive=True): if depth > 48: raise WorkerError("http_schema_depth_limit") if isinstance(value, list): - return [dereference(item, document, stack, depth+1) for item in value] + return [dereference(item, document, stack, depth+1) for item in value] if recursive else value if not isinstance(value, dict): return value if "$ref" in value: @@ -368,11 +368,11 @@ def dereference(value, document, stack=(), depth=0): target = target[part.replace("~1", "/").replace("~0", "~")] except (KeyError, TypeError): raise WorkerError("http_schema_reference_missing") from None - resolved = dereference(target, document, (*stack,reference), depth+1) + resolved = dereference(target, document, (*stack,reference), depth+1, recursive=recursive) if len(value) != 1: raise WorkerError("http_schema_ref_siblings_unsupported") return resolved - return {key:dereference(item, document, stack, depth+1) for key,item in value.items()} + return {key:dereference(item, document, stack, depth+1) for key,item in value.items()} if recursive else value def check_http_schema(value): @@ -449,6 +449,19 @@ 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 http["contract"].get("excluded_parameters"): + excluded = http["contract"]["excluded_parameters"] + excluded_rows = [] + for parameter in excluded[:32]: + row = {"location": parameter["location"], "name": preview(parameter["name"], 1024), + "reason": parameter["reason"]} + if len(canonical(excluded_rows + [row])) > 8 * 1024: + break + excluded_rows.append(row) + http_view.update({"excluded_parameters": excluded_rows, "excluded_parameter_count": len(excluded), + "excluded_parameters_digest": digest(excluded), + "excluded_parameters_complete": len(excluded_rows) == len(excluded) + and all(not row["name"]["omitted"] for row in excluded_rows)}) result = {"ok": True, **summarize(envelope), "view_version": 2, "contract_kind": "discovery_view", "effect_class": "unclassified", "full_tool_digest": digest(tool), "complete": False, @@ -469,6 +482,31 @@ def describe_view(envelope, tool, http, offset): return result +def http_read_parameter(parameter, source): + location, name = parameter.get("in"), parameter.get("name") + if location not in ("path", "query"): + raise WorkerError("http_parameter_location_unsupported") + if "content" in parameter: + if "schema" in parameter or not isinstance(parameter["content"], dict) or len(parameter["content"]) != 1: + raise WorkerError("http_original_contract_invalid") + raise WorkerError("http_parameter_serialization_unsupported") + schema = parameter.get("schema") + if "schema" not in parameter and source.get("swagger") == "2.0" and "type" in parameter: + schema = {key: value for key, value in parameter.items() if key in ( + "type", "enum", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", + "minLength", "maxLength", "pattern", "format", "default", "multipleOf")} + if not isinstance(schema, (dict, bool)): + raise WorkerError("http_original_contract_invalid") + schema = dereference(schema, source) + if not isinstance(schema, dict) or schema.get("type") not in ("string", "integer", "number", "boolean"): + raise WorkerError("http_parameter_type_unsupported") + if parameter.get("style", "simple" if location == "path" else "form") != ("simple" if location == "path" else "form") or parameter.get("allowReserved", False): + raise WorkerError("http_parameter_serialization_unsupported") + check_http_schema(schema) + return {"location": location, "name": name, + "required": bool(parameter.get("required")) or location == "path", "schema": schema} + + def http_read_contract(artifact, tool): # The normalized graph is discovery data. Invoke only against retained source schemas. metadata = tool.get("metadata", {}) @@ -479,28 +517,51 @@ def http_read_contract(artifact, tool): method, path = metadata.get("method"), metadata.get("path") if method != "get": raise WorkerError("http_method_not_read_approved") - item = dereference(source["paths"][path], source) + item = dereference(source["paths"][path], source, recursive=False) operation = item[method] - if operation.get("requestBody") or any(p.get("in") == "body" for p in operation.get("parameters", [])): + if not isinstance(operation, dict): + raise WorkerError("http_original_contract_invalid") + 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]): raise WorkerError("http_get_body_unsupported") + # Operation overrides are allowed across groups; duplicates within one group are invalid. merged = {} - for parameter in [*item.get("parameters", []), *operation.get("parameters", [])]: - parameter = dereference(parameter, source) - location, name = parameter.get("in"), parameter.get("name") - if location not in ("path", "query") or not isinstance(name, str) or not name: - raise WorkerError("http_parameter_location_unsupported") - schema = parameter.get("schema") - if schema is None and source.get("swagger") == "2.0": - schema = {key:value for key,value in parameter.items() if key in ("type","enum","minimum","maximum","exclusiveMinimum","exclusiveMaximum","minLength","maxLength","pattern","format","default","multipleOf")} - if not isinstance(schema, dict) or schema.get("type") not in ("string","integer","number","boolean"): - raise WorkerError("http_parameter_type_unsupported") - if parameter.get("style", "simple" if location == "path" else "form") != ("simple" if location == "path" else "form") or parameter.get("allowReserved",False) or parameter.get("content"): - raise WorkerError("http_parameter_serialization_unsupported") - merged[(location,name)] = {"location":location,"name":name,"required":bool(parameter.get("required")) or location=="path","schema":dereference(schema,source)} + for group in parameter_groups: + seen = set() + for parameter in group: + parameter = dereference(parameter, source, recursive=False) + if not isinstance(parameter, dict): + raise WorkerError("http_original_contract_invalid") + location, name = parameter.get("in"), parameter.get("name") + if location not in ("path", "query", "header", "cookie") or not isinstance(name, str) or not name: + raise WorkerError("http_parameter_location_unsupported") + key = (location, name) + if key in seen or ("required" in parameter and type(parameter["required"]) is not bool): + raise WorkerError("http_original_contract_invalid") + seen.add(key) + merged[key] = parameter + included, excluded = [], [] + for (location, name), parameter in merged.items(): + try: + included.append(http_read_parameter(parameter, source)) + except WorkerError as error: + # Only known unsupported parameter shapes can be omitted. Structural, + # unknown parameter identity, response and security failures still close the operation. + if parameter.get("required") or location == "path" or str(error) not in ( + "http_parameter_location_unsupported", "http_parameter_type_unsupported", + "http_parameter_serialization_unsupported", "http_schema_dialect_unsupported", + "http_schema_reference_unsupported", "http_schema_ref_siblings_unsupported", + "http_schema_depth_limit"): + raise + excluded.append({"location": location, "name": name, "reason": str(error)}) responses = {} for status,response in operation.get("responses", {}).items(): if not str(status).isdigit() or not 200 <= int(status) <= 299: continue + response = dereference(response, source) if source.get("swagger") == "2.0": produces = operation.get("produces", source.get("produces", [])) if produces and "application/json" not in produces: @@ -522,11 +583,11 @@ def http_read_contract(artifact, tool): scheme = dereference(schemes.get(name,{}),source) if scheme.get("type") != "http" or scheme.get("scheme", "").lower() != "bearer" or scopes: raise WorkerError("http_security_unsupported") - for parameter in merged.values(): - check_http_schema(parameter["schema"]) for schema in responses.values(): check_http_schema(schema) - contract = {"format_version":1,"method":"GET","path":path,"base_path":source.get("basePath", ""),"parameters":list(merged.values()),"responses":responses,"bearer_required":bool(security) and not any(not requirement for requirement in security),"source_digest":digest(source)} + 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)} + if excluded: + contract["excluded_parameters"] = excluded if len(canonical(contract)) > 8 * 1024 * 1024: raise WorkerError("http_contract_size_limit") return {"supported":True,"contract_digest":digest(contract),"contract":contract} diff --git a/docs/adr/0081-optional-http-parameters.md b/docs/adr/0081-optional-http-parameters.md new file mode 100644 index 0000000..1f04c47 --- /dev/null +++ b/docs/adr/0081-optional-http-parameters.md @@ -0,0 +1,57 @@ +# ADR-0081: 지원 밖인 선택 HTTP 파라미터는 실행 가능한 부분집합에서 제외한다 + +- 상태: Accepted +- 기준일: 2026-10-31 +- 분류: 범용 HTTP 조회·모델용 describe 확장. 특정 시스템의 이름·파라미터·업무 규칙을 판정에 사용하지 않는다. +- 후속 결정: [HTTP 조회 계약](../development/http-read-tools.md)과 + [제한된 describe](../development/bounded-tool-describe.md)의 파라미터 투영. + +## 결정 + +1. path-level과 operation-level 파라미터를 `(in, name)`으로 병합해 operation override를 먼저 적용한다. + 각 유효 파라미터를 기존 scalar·location·직렬화·schema 지원 규칙으로 따로 판정한다. +2. 지원 밖인 선택 파라미터만 실행 계약의 `parameters`에서 뺀다. 계약에 `excluded_parameters`를 + 추가하고 원본 name·location·고정 reason code를 기록한다. required 파라미터와 path 파라미터는 + 지원 밖이면 operation 전체를 거절한다. path는 잘못된 `required:false`가 있어도 생략하지 않는다. +3. 파라미터 identity와 required 여부를 확인할 수 없는 참조, 없는 로컬 참조, 잘못된 구조, + 응답·보안·method·GET body 계약 오류를 선택 파라미터처럼 무시하지 않는다. + 선택 파라미터 schema의 지원 밖 참조·dialect·깊이 제한은 제외 이유로 기록할 수 있다. + 참조 해제는 selected path/parameter object 경계와 schema 경계를 분리해 다른 operation이나 + 제외할 선택 schema 때문에 지원 가능한 부분집합을 먼저 잃지 않도록 한다. +4. 모델용 `describe_view`에는 포함한 파라미터만 기존 `tool.parameters` 페이지로 표시한다. + `http_read.excluded_parameters`에는 이름 preview·location·reason을, 별도 count·digest·complete에 + 전체 목록 수·digest와 표시의 완전성을 기록한다. 최대 32개/8 KiB, 이름 preview 1 KiB로 제한하고 + 기존 48 KiB describe 한도를 유지한다. 제외된 이름은 실행 가능한 필터가 아니다. +5. 실행은 포함한 파라미터만 허용하는 기존 host `request_url` 경계를 사용한다. 제외한 파라미터를 + path/query 입력에 넣으면 HTTP 요청 전에 unknown parameter로 거절한다. 모델 표시만 숨기지 않는다. +6. 제외 목록도 전체 실행 계약 digest에 넣는다. 원본 전체 source digest도 유지한다. + 제외가 없는 기존 계약에는 빈 목록을 추가하지 않아 기존 supported 계약의 canonical 값은 유지한다. + 이번 단계에서는 배열 직렬화·POST·Run bearer override를 추가하지 않는다. + +## 검증 + +- 설계: OpenAPI 3 assets와 Swagger 2 calendar의 선택 배열·header 제외, scalar 유지, + required 배열/header·path 거절, override 순서, 선택 serialization/dialect/ref 제외를 검사한다. +- 별도 shipment·sensor의 실제 import→모델 describe→GET→완료에서 선택 배열이 모델 입력 목록에서 + 빠지고 제외 이유가 보이는지 확인한다. 제외한 배열을 실행 입력에 다시 넣으면 API 호출은 0회다. +- 이름이 큰 제외 파라미터 100개에서도 describe 한도와 불완전 표시를 유지하며 목록 변경이 digest를 바꾼다. +- 기존 shipment·sensor 계약은 변경 전 main worker와 canonical 값이 같음을 확인했다. +- 기존 HTTP 실패 fixture는 ADR-0052 이전의 즉시 종료를 기대해 main과 같은 계약에서도 실패했다. + 현재 read 실패 복구 계약에 맞춰 모델이 실패 사실을 보고하게 고쳤다. 302·500·잘못된 JSON/schema· + 크기 초과·credential echo 7종에서 failed Receipt·모델에 실패 status만 전달·toolOutputs 없음· + API 1회·재호출 없음·secret 비노출을 검사한다. 대형 응답 schema 검증 fixture도 같은 방식으로 + failed Receipt를 직접 확인하도록 보완했다. 엔진의 실패 처리 계약은 바꾸지 않았다. + +## 범위와 근거 + +외부 커머스 API는 통합 사례이며 이 설계·검증에는 사용하지 않는다. 그 API에서 실행 가능한 operation 수나 +과업 성공률은 아직 측정하지 않았다. 외부 연동 담당자는 로컬 OpenAPI 생성기·endpoint·인증·비교 실행기를 +준비하고, xgen은 명세 기반 실행 가능성·직렬화·권한·기록·복구를 맡는다. + +[OpenAPI 3.1 Parameter Object](https://spec.openapis.org/oas/v3.1.0.html#parameter-object)의 location·required와 +operation의 파라미터 override 규칙을 따른다. 선택 파라미터의 제외는 xgen의 제한된 실행 subset 정책이다. + +로컬 회귀: Rust 778 passed·4 ignored, system Python+graph-tool-call 0.47.0+컴파일 CLI 전체 139개 검사 +(7 skipped), 그중 HTTP 관련 18개 모두 실행·통과. Clippy·fmt·protocol·문서·배포 workflow 검사 통과. +UV standalone Python 3.12에는 pidfd_open이 없어 기존 평가 process-cleanup 검사가 실패하므로, +해당 기능이 있는 system Python의 별도 venv로 graph-tool-call을 고정해 전체 검사를 실행했다. diff --git a/docs/development/http-read-tools.md b/docs/development/http-read-tools.md index 164292c..e8c9513 100644 --- a/docs/development/http-read-tools.md +++ b/docs/development/http-read-tools.md @@ -50,6 +50,10 @@ agent loop에는 연결이 있을 때 `xgen.http/read@1.0.0`이 추가된다. ## 지원 범위와 한도 - 새로 가져온 **로컬 JSON 문서만으로 구성한 collection**의 GET. OpenAPI 3.1/3.0 및 Swagger 2의 아래 부분집합을 지원한다. +- 지원 밖인 선택 파라미터는 operation을 막지 않고 실행 입력에서 제외한다. 모델용 describe의 + `http_read.excluded_parameters`에 이름 preview·위치·고정 이유를 표시하고 count/digest/complete를 + 함께 준다. 제외한 이름을 입력에 넣으면 HTTP 호출 전에 거절한다. required와 path 파라미터가 + 지원 밖이면 operation 전체를 거절한다. [ADR-0081](../adr/0081-optional-http-parameters.md). - scalar path/query: string/integer/number/boolean, simple path·form query, 원본 required·enum·pattern·수치/길이 제약. 값은 URL library로 encode한다. path의 dot segment·slash·역슬래시·percent 입력과 혼합 template는 거절한다. - 명시적인 숫자 2xx 응답의 `application/json` 스키마. 실제 응답도 해당 Content-Type이어야 한다. 순환 없는 문서 내부 `$ref`를 해제한다. - 인증 없음 또는 OpenAPI HTTP bearer. OAuth·API key·cookie·header parameter·본문·배열 파라미터·외부/순환 ref·ref sibling·nullable/legacy boolean exclusive bound·dynamic ref는 호출 전에 거절한다. JSON Schema 2020-12에 호환되는 스키마만 실행한다. format은 annotation이며 추가 format 보증은 없다. diff --git a/docs/development/tool-integration-todo.md b/docs/development/tool-integration-todo.md index 8f2b977..ae0f519 100644 --- a/docs/development/tool-integration-todo.md +++ b/docs/development/tool-integration-todo.md @@ -72,7 +72,7 @@ - [x] **xgen:** `xgen.http/read` 프록시로 exact 외부 도구·입력·계약·target을 기존 권한·실행·Receipt·완료 검증 경로에 연결. 개별 외부 Definition/Instance 자동 admission은 2번 후속이다. - [x] **xgen:** 원본 로컬 JSON 명세 보존과 GET scalar path/query·2xx JSON 응답·bearer/no-auth 어댑터 구현. 조회 효과는 `connect --allow-get`으로 지정하고 Run read 승인을 유지한다. - [ ] **graph-tool-call:** 원격 URL/Swagger UI 수집 원본·출처 보존의 공개 계약을 검토·개선. 정규화된 발견 계약과 완전한 실행 계약을 구분한다. -- [ ] **xgen:** 원격 원본 수집 계약을 연결하고 기존 collection 실행 이행·연결 점검을 제공. header/body/배열·인증 방식의 HTTP 어댑터 지원을 확대한다. 지원하지 않는 계약을 추측해 호출하지 않는다. +- [ ] **xgen:** 원격 원본 수집 계약을 연결하고 기존 collection 실행 이행·연결 점검을 제공. header/body/배열·인증 방식의 HTTP 어댑터 지원을 확대한다. 지원하지 않는 계약을 추측해 호출하지 않는다. 구체적인 순서와 상태는 [HTTP 조회 확장 요청](#http-조회-확장-요청-2026-10-31)을 따른다. - [x] **xgen:** `tools connect` 주소·숨김 bearer 입력/OS secret store·환경 참조·불변 연결 저장·재사용 구현. 인증값은 그래프·worker·모델·Receipt에 넣지 않는다. - [ ] **xgen:** 원격 명세·지원 operation·인증 연결 점검, 연결 수정/삭제 UX와 실제 OS secret store backend 검증. collection 삭제·재빌드 명령(backend 버전을 올리면 기존 collection을 다시 import해야 함)도 포함한다. - 2026-10-13 완료분: `tools rebuild --name|--all`(import 때 보존한 로컬 JSON 사본을 digest로 확인해 현재 backend로 재빌드, HTTPS는 재수집, 교체 직전에 읽은 snapshot인지 확인), `tools disconnect`(그 연결의 secret을 먼저, 그다음 연결 파일 삭제; 실패하면 그대로 남아 재시도 가능), `tools remove`(연결을 먼저, 그다음 collection 삭제), `import`는 collection 없이 남은 연결이 있으면 거절(같은 이름의 새 API가 이전 연결을 물려받지 않음). `tools list`는 collection별 `ready`·`rebuild_required`·`unreadable` 상태를 내고, 새 Run은 막는 collection 이름을 stderr에 남긴다. worker 테스트 5개 추가·1개 수정, 연결 삭제 단위 테스트 3개, discovery 테스트 1개, 컴파일 CLI 전 과정 테스트 1개를 추가했다. 별도 리뷰에서 나온 4건(손상 gzip이 목록 전체를 실패시킴, 권한 오류를 연결 없음으로 처리, 손으로 지운 collection의 연결 상속, secret 삭제 실패 시 고아 secret)을 반영했다. @@ -130,3 +130,21 @@ - [ ] **평가:** 검증 결과로 검색·모델 분기 기본값 결정. 작업 성공률, 잘못된 실행·완료 주장, 호출·토큰·금액, 단계·시간을 기준으로 판단. 첫 구현 묶음은 **1번 계약·검증 계획 + 2번 최소 검색 연결**이다. 모델 재학습, STT 통합, 변경·등록 업무 자동화는 후속 단계다. + +## HTTP 조회 확장 요청 (2026-10-31) + +외부 통합 사례와 분리된 범용 OpenAPI 기능으로 순서대로 개발한다. 외부 operation 수·실행 가능 과업 수는 +생성된 명세를 import하고 검증하기 전에는 확정하지 않는다. 통합 측의 OpenAPI 생성기·서버·인증·모델· +비교 실행기는 해당 저장소 담당자의 작업이다. xgen 엔진에는 시스템별 규칙을 넣지 않는다. + +1. **구현·로컬 검증:** 지원 밖 선택 파라미터 제외, required/path 거절, describe 제외 이유, 제외 입력 호출 전 거절. + [ADR-0081](../adr/0081-optional-http-parameters.md). 단독 PR로 먼저 제공한다. +2. **다음:** OpenAPI 3 form/explode=true와 Swagger 2 multi 배열 query, scalar items 및 배열 제약 검증. + 다른 직렬화·object items는 1번의 제외/거절 규칙을 따른다. +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 누락 시 + 저장 token으로의 암묵적 fallback·서버 process 격리도 검증한다. +5. **별도 설계, 대회 후:** 승인 있는 POST/PUT/PATCH/DELETE 쓰기·host Idempotency-Key·NonIdempotent + Unknown의 no-retry. 1~4 완료 뒤 진행하며 이번 조회 확장에 포함하지 않는다. diff --git a/scripts/tests/test_bounded_tool_describe.py b/scripts/tests/test_bounded_tool_describe.py index 8a3c2d3..70e1581 100644 --- a/scripts/tests/test_bounded_tool_describe.py +++ b/scripts/tests/test_bounded_tool_describe.py @@ -2,6 +2,8 @@ import importlib.util import json import os +import re +import sqlite3 from pathlib import Path import unittest @@ -176,6 +178,18 @@ def complete(payload): self.assertEqual(len(self.calls),0) arguments["contractDigest"]=view["http_read"]["contract_digest"] self.requests.clear();self.response=invalid_body - result=self.run_cli();self.assertNotEqual(result.returncode,0) + def report_failure(payload): + context = self.context(payload) + self.assertEqual(context["toolOutputs"], []) + self.assertEqual(context["steps"][0]["status"], "failed") + return {"formatVersion":1,"kind":"completion_candidate","steps":[], + "summary":"Large-schema validation failed; no verified body available."} + self.proposals=[self.plan("read","xgen.http/read",arguments),report_failure] + result=self.run_cli();self.assertEqual(result.returncode,0,result.stderr.decode()) self.assertEqual(len(self.calls),1) + self.assertEqual(len(self.requests),2) self.assertNotIn(b'"body"',result.stdout) + run_id=re.search(rb"XGEN_STARTED run_id=(run-[a-f0-9]+)",result.stderr).group(1).decode() + with sqlite3.connect(self.state/"runs"/run_id/"run.sqlite3") as store: + receipts=[json.loads(row[0]) for row in store.execute("SELECT receipt_json FROM execution_receipts")] + self.assertEqual([receipt["status"] for receipt in receipts],["failed"]) diff --git a/scripts/tests/test_http_read_tools.py b/scripts/tests/test_http_read_tools.py index e4d83a7..e44f7a5 100644 --- a/scripts/tests/test_http_read_tools.py +++ b/scripts/tests/test_http_read_tools.py @@ -34,6 +34,122 @@ def held_out_spec(name): "responses":{"200":{"description":"Measurement","schema":{"type":"object","required":["values"],"properties":{"values":{"type":"array","items":{"type":"number"}}},"additionalProperties":False}}}}}}} +class OptionalParameterContractTests(unittest.TestCase): + @staticmethod + def describe(source, path="/assets", method="get"): + artifact = {"metadata": {"xgen_http_sources": {"fixture": source}}} + tool = {"name": "fixture", "metadata": {"source_url": "fixture", "path": path, "method": method}} + contract = WORKER.http_read_contract(artifact, tool) + return contract, tool + + def test_optional_unsupported_parameters_preserve_independent_contracts(self): + for source, path in [(asset_spec(), "/assets"), (calendar_spec(), "/events")]: + with self.subTest(path=path): + parameters = [{"name":"keep","in":"query","type":"string","schema":{"type":"string"}}, + {"name":"filters","in":"query","type":"array","schema":{"type":"array","items":{"type":"string"}}}, + {"name":"trace","in":"header","schema":{"type":"string"}}] + source["paths"][path]["get"]["parameters"] = parameters + result, tool = self.describe(source, path) + self.assertTrue(result["supported"], result) + contract = result["contract"] + self.assertEqual([p["name"] for p in contract["parameters"]], ["keep"]) + self.assertEqual(contract["excluded_parameters"], [ + {"location":"query","name":"filters","reason":"http_parameter_type_unsupported"}, + {"location":"header","name":"trace","reason":"http_parameter_location_unsupported"}]) + self.assertEqual(result["contract_digest"], WORKER.digest(contract)) + view = WORKER.describe_view({"collection":"fixture","artifact_digest":"a"*64,"artifact":{"tools":[tool],"metadata":{"source_count":1}}}, tool, result, 0) + self.assertEqual([p["name"]["text"] for p in view["tool"]["parameters"]], ["keep"]) + self.assertEqual([p["name"]["text"] for p in view["http_read"]["excluded_parameters"]], ["filters","trace"]) + self.assertTrue(view["http_read"]["excluded_parameters_complete"]) + + def test_required_shapes_and_path_parameters_still_close_the_operation(self): + for location, required in [("query",True),("header",True),("path",False)]: + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = [{"name":"bad","in":location,"required":required,"schema":{"type":"array","items":{"type":"string"}}}] + result, _ = self.describe(source) + self.assertFalse(result["supported"], result) + + def test_overrides_precede_support_classification(self): + source = asset_spec() + source["paths"]["/assets"]["parameters"] = [{"name":"filter","in":"query","required":True,"schema":{"type":"array","items":{"type":"string"}}}] + source["paths"]["/assets"]["get"]["parameters"] = [{"name":"filter","in":"query","schema":{"type":"string"}}] + result, _ = self.describe(source) + self.assertTrue(result["supported"], result) + self.assertEqual(result["contract"]["parameters"][0]["schema"], {"type":"string"}) + self.assertNotIn("excluded_parameters", result["contract"]) + + def test_missing_or_malformed_schema_is_not_treated_as_optional_unsupported_shape(self): + for fields in [{}, {"schema":None}, {"schema":"invalid"}, + {"schema":{"type":"string"},"content":{"application/json":{}}}]: + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = [{"name":"bad","in":"query",**fields}] + self.assertEqual(self.describe(source)[0], {"supported":False,"error":"http_original_contract_invalid"}) + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = [ + {"name":"json","in":"query","content":{"application/json":{"schema":{"type":"string"}}}}] + result, _ = self.describe(source) + self.assertTrue(result["supported"], result) + self.assertEqual(result["contract"]["excluded_parameters"][0]["reason"], "http_parameter_serialization_unsupported") + + def test_duplicate_or_malformed_parameters_do_not_remove_required_constraints(self): + for level in ["path", "operation"]: + source = asset_spec() + item = source["paths"]["/assets"] + owner = item if level == "path" else item["get"] + owner["parameters"] = [ + {"name":"same","in":"query","required":True,"schema":{"type":"string"}}, + {"name":"same","in":"query","schema":{"type":"array"}}] + self.assertEqual(self.describe(source)[0], {"supported":False,"error":"http_original_contract_invalid"}) + for bad in [[None], {"name":"not-a-list"}, [{"name":"bad","in":"query","required":0,"schema":{"type":"array"}}]]: + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = bad + self.assertEqual(self.describe(source)[0], {"supported":False,"error":"http_original_contract_invalid"}) + + def test_optional_schema_references_are_isolated_but_unknown_parameter_refs_are_not(self): + source = asset_spec() + operation = source["paths"]["/assets"]["get"] + operation["parameters"] = [{"name":"remote","in":"query","schema":{"$ref":"https://example.com/schema.json"}}] + result, _ = self.describe(source) + self.assertTrue(result["supported"], result) + self.assertEqual(result["contract"]["excluded_parameters"][0]["reason"], "http_schema_reference_unsupported") + operation["parameters"][0]["required"] = True + self.assertFalse(self.describe(source)[0]["supported"]) + operation["parameters"] = [{"$ref":"https://example.com/parameter.json"}] + self.assertFalse(self.describe(source)[0]["supported"]) + source["components"] = {"schemas":{"Scalar":{"type":"integer","minimum":2}}} + operation["parameters"] = [{"name":"value","in":"query","required":True,"schema":{"$ref":"#/components/schemas/Scalar"}}] + self.assertEqual(self.describe(source)[0]["contract"]["parameters"][0]["schema"], {"type":"integer","minimum":2}) + + def test_exclusion_metadata_is_bounded_and_committed_in_the_contract_digest(self): + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = [ + {"name": "filter" + str(i) + "x" * 2048, "in":"query", "schema":{"type":"array"}} + for i in range(100)] + result, tool = self.describe(source) + self.assertTrue(result["supported"], result) + view = WORKER.describe_view({"collection":"fixture","artifact_digest":"a"*64, + "artifact":{"tools":[tool],"metadata":{"source_count":1}}}, tool, result, 0) + self.assertLessEqual(len(WORKER.canonical(view)), 48 * 1024) + self.assertEqual(view["http_read"]["excluded_parameter_count"], 100) + self.assertFalse(view["http_read"]["excluded_parameters_complete"]) + old_digest = result["contract_digest"] + source["paths"]["/assets"]["get"]["parameters"][0]["name"] += "changed" + self.assertNotEqual(self.describe(source)[0]["contract_digest"], old_digest) + + def test_optional_serialization_and_dialect_are_reported_but_security_is_not_dropped(self): + source = asset_spec() + source["paths"]["/assets"]["get"]["parameters"] = [ + {"name":"shape","in":"query","style":"deepObject","schema":{"type":"string"}}, + {"name":"legacy","in":"query","schema":{"type":"string","nullable":True}}] + result, _ = self.describe(source) + self.assertTrue(result["supported"], result) + self.assertEqual([p["reason"] for p in result["contract"]["excluded_parameters"]], + ["http_parameter_serialization_unsupported","http_schema_dialect_unsupported"]) + source["security"] = [{"unsupported":[]}] + result, _ = self.describe(source) + self.assertEqual(result, {"supported":False,"error":"http_security_unsupported"}) + + @unittest.skipUnless(importlib.util.find_spec("graph_tool_call"),"run with pinned graph-tool-call environment") class ContractTests(unittest.TestCase): setUp = base.WorkerTests.setUp @@ -61,8 +177,8 @@ def test_unsupported_contracts_are_not_invented(self): source=asset_spec() op=source["paths"]["/assets"]["get"] if kind=="post": source["paths"]["/assets"]={"post":op} - elif kind=="header": op["parameters"]=[{"name":"x","in":"header","schema":{"type":"string"}}] - elif kind=="array": op["parameters"]=[{"name":"x","in":"query","schema":{"type":"array","items":{"type":"string"}}}] + elif kind=="header": op["parameters"]=[{"name":"x","in":"header","required":True,"schema":{"type":"string"}}] + elif kind=="array": op["parameters"]=[{"name":"x","in":"query","required":True,"schema":{"type":"array","items":{"type":"string"}}}] elif kind=="xml": op["responses"]["200"]["content"]={"application/xml":{"schema":{"type":"object"}}} else: source["components"]={"schemas":{"Recursive":{"$ref":"#/components/schemas/Recursive"}}} @@ -138,8 +254,8 @@ def cli(self,*args,check=True): self.assertNotIn(self.secret.encode(),result.stdout+result.stderr) return result - def install(self,name="shipments"): - source=self.directory/(name+".json");source.write_text(json.dumps(held_out_spec(name))) + def install(self,name="shipments",spec=None): + source=self.directory/(name+".json");source.write_text(json.dumps(held_out_spec(name) if spec is None else spec)) self.cli("tools","import","--name",name,"--source",str(source)) 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"] @@ -205,17 +321,60 @@ def complete(payload): for path in self.state.rglob("*"): if path.is_file(): self.assertNotIn(self.secret.encode(),path.read_bytes(),str(path)) + def test_optional_array_is_hidden_and_injected_input_is_rejected_before_http(self): + for name, path, tool in [("shipments","/shipments/{tracking}","readShipment"), + ("sensors","/sensors/{sensorId}","readSensor")]: + with self.subTest(name=name): + source = held_out_spec(name) + parameter = {"name":"states","in":"query","required":False, + "schema":{"type":"array","items":{"type":"string"}}} + if name == "sensors": + parameter = {"name":"states","in":"query","required":False, + "type":"array","items":{"type":"integer"}} + source["paths"][path]["get"]["parameters"].append(parameter) + arguments = self.install(name, source) + view = json.loads(self.cli("tools","describe","--name",name,tool).stdout) + self.assertNotIn("states", [row["name"] for row in view["http_read"]["contract"]["parameters"]]) + self.assertEqual(view["http_read"]["contract"]["excluded_parameters"][0]["name"], "states") + self.assertEqual(view["http_read"]["contract"]["excluded_parameters"][0]["reason"], "http_parameter_type_unsupported") + self.response = {"tracking":"A42","delivered":True} if name == "shipments" else {"values":[2.5]} + self.calls.clear(); self.requests.clear() + self.proposals = [self.plan("describe", "xgen.tools/describe", {"collection":name,"tool":tool}), + self.plan("read", "xgen.http/read", arguments)] + result = self.run_cli() + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(len(self.calls), 1) + self.assertNotIn("states", self.calls[0][0]) + model_view = self.context(self.requests[1])["toolOutputs"][0]["output"] + self.assertNotIn("states", [row["name"]["text"] for row in model_view["tool"]["parameters"]]) + self.assertEqual(model_view["http_read"]["excluded_parameters"][0]["name"]["text"], "states") + self.calls.clear(); self.requests.clear() + bad = copy.deepcopy(arguments) + bad["parameters"]["query"]["states"] = ["open"] + self.proposals = [self.plan("bad", "xgen.http/read", bad)] + result = self.run_cli() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls, []) + 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())] - for status,body in cases: + for case_index, (status,body) in enumerate(cases): self.calls.clear();self.requests.clear();self.status=status;self.response=body self.redirect=f"http://127.0.0.1:{self.api.server_port}/must-not-follow" - self.proposals=[self.plan("read","xgen.http/read",arguments)] + def report_failure(payload): + context = self.context(payload) + self.assertEqual(context["toolOutputs"], []) + self.assertEqual(context["steps"][0]["status"], "failed", f"HTTP failure case {case_index}") + return {"formatVersion":1,"kind":"completion_candidate","steps":[], + "summary":"Fixture API failed; no verified data available."} + self.proposals=[self.plan("read","xgen.http/read",arguments), report_failure] result=self.run_cli() - self.assertNotEqual(result.returncode,0) + # ADR-0052: failed read Receipts are observations; the model may report failure. + self.assertEqual(result.returncode,0,result.stderr.decode()) + self.assertIn(b"no verified data available",result.stdout) self.assertEqual(len(self.calls),1) - self.assertEqual(len(self.requests),1) + self.assertEqual(len(self.requests),2) # Each run stores a failed receipt, never the rejected response body. for db in (self.state/"runs").glob("*/run.sqlite3"): with sqlite3.connect(db) as store: