Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 연결, 화면 정보 변환 어댑터.

## 역할별 모델 연결: 설계 후보

Expand Down Expand Up @@ -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 상태를 정본으로 참조한다.
4 changes: 4 additions & 0 deletions crates/xgen-cli/src/allow_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 80 additions & 3 deletions crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ pub struct LocalRunRequest {
pub allow_executables: Vec<String>,
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<String, String>,
pub allow_write: bool,
pub allow_execute: bool,
pub max_ticks: u32,
Expand Down Expand Up @@ -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,
Expand All @@ -204,6 +207,8 @@ pub struct LocalResumeRequest {
pub allow_executables: Vec<String>,
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<String, String>,
pub allow_write: bool,
pub allow_execute: bool,
pub max_ticks: u32,
Expand Down Expand Up @@ -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."
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1356,6 +1380,7 @@ where
process.as_ref(),
manifest.tool_discovery_snapshots(),
manifest.http_read_connections(),
manifest.http_bearer_overrides(),
)?
{
let current = process
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3125,13 +3151,15 @@ fn execution_profile_with_discovery(
process: Option<&ProcessTooling>,
snapshots: &graph_discovery::Snapshots,
connections: &crate::http_read::Connections,
overrides: &crate::http_read::BearerOverrides,
) -> Result<String, PublicRunError> {
let local_digest = local_execution_profile_digest(workspace, catalog, process)?;
if snapshots.is_empty() {
return Ok(local_digest);
}
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",
Expand Down Expand Up @@ -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()
Expand All @@ -3654,15 +3683,17 @@ 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(
&workspace,
&catalog,
None,
&second,
&crate::http_read::Connections::new()
&crate::http_read::Connections::new(),
&crate::http_read::BearerOverrides::new()
)
.unwrap()
);
Expand All @@ -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!(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion crates/xgen-cli/src/graph_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
}
Expand Down Expand Up @@ -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(),
})
}
Expand Down Expand Up @@ -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<Self, ()> {
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)
}
Expand Down Expand Up @@ -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")
};
Expand Down
48 changes: 45 additions & 3 deletions crates/xgen-cli/src/http_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ pub(crate) type Connections = BTreeMap<String, Connection>;
const MAX_BODY: usize = 32 * 1024;
const MAX_TARGET_BYTES: usize = 8 * 1024;

pub(crate) type BearerOverrides = BTreeMap<String, String>;

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 {
Expand Down Expand Up @@ -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<Option<Zeroizing<String>>, &'static str> {
let secret = if let Some(name) = &self.bearer_env {
fn credential(
&self,
override_env: Option<&str>,
) -> Result<Option<Zeroizing<String>>, &'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")?,
))
Expand Down Expand Up @@ -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);
Expand Down
Loading