Files
Genarrative/rust/crates/agent-cli/src/main.rs
T
kdletters 36ae9425e9 完善 AGC 独立 Agent 试验验证
支持使用 Fake Provider 验证跨进程 Agent 回合

修正独立 Agent 与排队模型的匹配和子进程超时回收

补充 AGC 到独立 App Server 的真实子进程 smoke
2026-09-10 13:07:09 +08:00

1945 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 面向人和脚本的最小 CLI。
//!
//! 不引入参数解析框架,保持首个可执行程序容易审查和嵌入。复杂部署可以
//! 直接使用 `agent-host` 库 API。
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use agent_app::{AgentTomlConfig, McpAuthToml, effective_model, queued_run_metadata};
use agent_codex::CodexCliBackend;
use agent_host::{AgentHost, HostRunHandle, HostRunOutput, McpContextSelection};
use agent_mcp::{McpAuthEnv, McpClientOptions, McpServerConfig, McpTransportConfig};
use agent_runtime_core::{ApprovalDecision, Message, PromptBuilder};
use agent_runtime_engine::AllowList;
use agent_skills::SkillLoader;
use serde::Serialize;
use serde_json::{Value, json};
mod app_server;
/// `reconcile` 保留旧的单 run 入口,并用显式 `--stale` 选择一次有界扫描。
/// 默认值与 Runtime 的硬上限一致;扫描本身仍由 Host/Runtime 原子执行。
const DEFAULT_STALE_RECONCILE_LIMIT: usize = 256;
#[derive(Clone, Debug, Eq, PartialEq)]
enum ReconcileArgs {
Run(String),
Stale { limit: usize },
}
fn parse_reconcile_args(args: Vec<String>) -> Result<ReconcileArgs, String> {
let Some(first) = args.first() else {
return Err("reconcile 需要 run_id 或 --stale [limit]".to_owned());
};
if first == "--stale" {
if args.len() > 2 {
return Err("reconcile --stale 最多接受一个 limit".to_owned());
}
let limit = args
.get(1)
.map(|value| {
value
.parse::<usize>()
.map_err(|_| format!("reconcile stale limit 无效: {value}"))
})
.transpose()?
.unwrap_or(DEFAULT_STALE_RECONCILE_LIMIT);
return Ok(ReconcileArgs::Stale { limit });
}
if args.len() != 1 {
return Err(
"reconcile <run_id> 只接受一个 run_id;批量扫描请使用 --stale [limit]".to_owned(),
);
}
Ok(ReconcileArgs::Run(first.clone()))
}
fn main() {
if let Err(error) = run() {
eprintln!("agent: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut args = env::args().skip(1);
let command = args.next().unwrap_or_else(|| "run".to_owned());
// 服务入口独立校验参数,不能把未知选项误当任务,也不读取 stdin 作为 prompt。
if command == "app-server" {
let options = args.collect::<Vec<_>>();
if options == ["--help"] || options == ["-h"] {
println!(
"用法: agent app-server --stdio\nJSON-RPC 2.0 JSONL;先 initialize,再 run/start。"
);
return Ok(());
}
if options != ["--stdio"] {
return Err("用法: agent app-server --stdio".into());
}
return app_server::run(AgentTomlConfig::load()?);
}
let config = AgentTomlConfig::load()?;
let db = config.db_path();
match command.as_str() {
"run" => {
let (run_options, task_args) = parse_run_options(args.collect())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
let streaming = run_options.streaming.unwrap_or_else(|| config.streaming());
let task = task_args.join(" ");
let task = if task.is_empty() {
"请简短介绍你自己".to_owned()
} else {
task
};
let messages = prompt_messages_with_config(&task, &config)?;
if run_options.background {
// 排队阶段只需要本地 SQLite/Core;不要因为 MCP/Provider
// 暂时不可用而阻止 durable run 身份落盘。真正的外部配置在
// 隐藏 worker 内重建并在执行前握手。
let host = AgentHost::open(&db)?;
let provider = config.provider();
let metadata = queued_run_metadata(&config, &provider);
let handle =
host.prepare_run_with_messages_and_metadata(task, messages, metadata)?;
let pid = spawn_worker_or_fail_unclaimed(&host, &db, &handle.run_id, streaming)?;
print_queued_result(&handle, pid, run_options.jsonl)?;
} else {
let host = open_configured_host(&db, &config)?;
let result = if streaming {
host.run_with_messages_streaming(task, messages)?
} else {
host.run_with_messages(task, messages)?
};
print_run_result(&result, run_options.jsonl)?;
}
}
"worker" => {
let run_id = args.next().ok_or("worker 需要 run_id")?;
// worker 读取已持久化的初始消息;扩展配置仍需在进程内重建,
// 这样 Skill/MCP 工具会在真正执行前注册到同一 Host。
let host = match open_configured_host(&db, &config) {
Ok(host) => host,
Err(error) => {
// 配置失败发生在 claim 之前;用裸 Host 把 durable run
// 收束为 failed,避免后台 worker 静默退出后留下 queued。
let control_result = AgentHost::open(&db).and_then(|control| {
control
.fail_unclaimed_run(&run_id, "worker 配置初始化失败")
.map(|_| ())
});
return match control_result {
Ok(()) => Err(format!(
"worker 配置初始化失败(run 已标记 failed): {error}"
)
.into()),
Err(control_error) => Err(format!(
"worker 配置初始化失败且无法收口 run: {error}; 收口错误: {control_error}"
)
.into()),
};
}
};
let streaming = env::var("AGENT_STREAM_WORKER").is_ok_and(|value| value == "1");
match if streaming {
host.run_existing_streaming(&run_id)
} else {
host.run_existing(&run_id)
} {
Ok(result) => println!("{}", serde_json::to_string_pretty(&result)?),
Err(error) if error.is_cancelled() => return Ok(()),
Err(error) => return Err(error.into()),
}
}
"cancel" => {
let run_id = args.next().ok_or("cancel 需要 run_id")?;
let host = AgentHost::open(&db)?;
let record = host.cancel(&run_id)?;
println!("{}", serde_json::to_string_pretty(&record)?);
}
"approval" => {
let subcommand = args
.next()
.ok_or("approval 需要子命令:list/get/allow/deny/resume")?;
let host = AgentHost::open(&db)?;
match subcommand.as_str() {
"list" => {
let run_id = args.next().ok_or("approval list 需要 run_id")?;
let approvals = host.list_approvals(&run_id)?;
let approvals = approvals
.iter()
.map(redacted_approval_view)
.collect::<Result<Vec<_>, _>>()?;
println!("{}", serde_json::to_string_pretty(&approvals)?);
}
"get" => {
let approval_id = args.next().ok_or("approval get 需要 approval_id")?;
let approval = host
.get_approval(&approval_id)?
.ok_or("找不到指定 approval")?;
println!(
"{}",
serde_json::to_string_pretty(&redacted_approval_view(&approval)?)?
);
}
"allow" => {
let approval_id = args.next().ok_or("approval allow 需要 approval_id")?;
let approval = host.resolve_approval(&approval_id, ApprovalDecision::Allow)?;
println!(
"{}",
serde_json::to_string_pretty(&redacted_approval_view(&approval)?)?
);
}
"deny" => {
let approval_id = args.next().ok_or("approval deny 需要 approval_id")?;
let reason = args.collect::<Vec<_>>().join(" ");
if reason.trim().is_empty() {
return Err("approval deny 需要拒绝原因".into());
}
let approval =
host.resolve_approval(&approval_id, ApprovalDecision::Deny { reason })?;
println!(
"{}",
serde_json::to_string_pretty(&redacted_approval_view(&approval)?)?
);
}
"resume" => {
let approval_id = args.next().ok_or("approval resume 需要 approval_id")?;
let record = host.resume_approval(&approval_id)?;
let pid =
spawn_worker_or_fail_unclaimed(&host, &db, &record.id, config.streaming())?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": record.status,
"worker_pid": pid,
"run_id": record.id,
"approval_id": approval_id
}))?
);
}
other => {
return Err(format!(
"未知 approval 子命令: {other}(支持 list/get/allow/deny/resume"
)
.into());
}
}
}
"resume" => {
let run_id = args.next().ok_or("resume 需要 run_id")?;
let host = AgentHost::open(&db)?;
let record = host.get_run(&run_id)?.ok_or("找不到指定 run")?;
if record.status != "queued" {
return Err(format!(
"只允许启动尚未领取的 queued run,当前状态为 {}running 请先 reconcile(不会自动重放)",
record.status
)
.into());
}
if host.read_checkpoint(&run_id)?.is_some() {
return Err(
"queued run 已有 checkpoint,请先用 resume-safe 完成显式 safe 恢复"
.to_owned()
.into(),
);
}
let pid = spawn_worker_or_fail_unclaimed(&host, &db, &run_id, config.streaming())?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "queued",
"worker_pid": pid,
"run_id": run_id
}))?
);
}
"resume-safe" => {
let run_id = args.next().ok_or("resume-safe 需要 run_id")?;
let host = AgentHost::open(&db)?;
let record = host.requeue_safe_run(&run_id)?;
let pid = spawn_worker_or_fail_unclaimed(&host, &db, &run_id, config.streaming())?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": record.status,
"worker_pid": pid,
"run_id": run_id,
"checkpoint": "safe"
}))?
);
}
"reconcile" => {
let host = AgentHost::open(&db)?;
match parse_reconcile_args(args.collect())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
{
ReconcileArgs::Run(run_id) => {
let record = host.reconcile_expired_run(&run_id)?;
println!("{}", serde_json::to_string_pretty(&record)?);
}
ReconcileArgs::Stale { limit } => {
let records = host.reconcile_stale_runs(limit)?;
println!("{}", serde_json::to_string_pretty(&records)?);
}
}
}
"reconcile-provider" => {
let run_id = args.next().ok_or("reconcile-provider 需要 run_id")?;
let request_id = args
.next()
.ok_or("reconcile-provider 需要 provider_request_id")?;
let messages_arg = args
.next()
.ok_or("reconcile-provider 需要消息 JSON 文件路径(或 - 读取 stdin")?;
let messages = read_messages_arg(&messages_arg)?;
let host = AgentHost::open(&db)?;
let checkpoint = host.reconcile_provider_result(&run_id, &request_id, messages)?;
println!("{}", serde_json::to_string_pretty(&checkpoint)?);
}
"reconcile-tool" => {
let run_id = args.next().ok_or("reconcile-tool 需要 run_id")?;
let call_id = args.next().ok_or("reconcile-tool 需要 tool_call_id")?;
let messages_arg = args
.next()
.ok_or("reconcile-tool 需要消息 JSON 文件路径(或 - 读取 stdin")?;
let messages = read_messages_arg(&messages_arg)?;
let host = AgentHost::open(&db)?;
let checkpoint = host.reconcile_tool_result(&run_id, &call_id, messages)?;
println!("{}", serde_json::to_string_pretty(&checkpoint)?);
}
"checkpoint" => {
let run_id = args.next().ok_or("checkpoint 需要 run_id")?;
let host = AgentHost::open(&db)?;
let checkpoint = host.read_checkpoint(&run_id)?;
println!("{}", serde_json::to_string_pretty(&checkpoint)?);
}
"inspect" => {
let run_id = args.next().ok_or("inspect 需要 run_id")?;
let host = AgentHost::open(&db)?;
let record = host.get_run(&run_id)?.ok_or("找不到指定 run")?;
println!("{}", serde_json::to_string_pretty(&record)?);
}
"export" => {
let run_id = args.next().ok_or("export 需要 run_id")?;
let host = AgentHost::open(&db)?;
let mut output = Vec::new();
let count = host.export_jsonl(&run_id, &mut output)?;
print!("{}", String::from_utf8(output)?);
eprintln!("导出 {count} 条记录");
}
"skills" => {
let subcommand = args.next().unwrap_or_else(|| "list".to_owned());
match subcommand.as_str() {
"list" => {
let definitions = list_skills(&config)?;
println!("{}", serde_json::to_string_pretty(&definitions)?);
}
other => return Err(format!("未知 skills 子命令: {other}").into()),
}
}
"mcp" => {
let subcommand = args.next().unwrap_or_else(|| "list".to_owned());
match subcommand.as_str() {
"list" => {
let snapshot = list_mcp(&config)?;
println!("{}", serde_json::to_string_pretty(&snapshot)?);
}
other => return Err(format!("未知 mcp 子命令: {other}").into()),
}
}
"codex" => {
let subcommand = args.next().unwrap_or_else(|| "validate".to_owned());
match subcommand.as_str() {
"validate" => {
let Some(cli) = config.codex.cli.clone() else {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"configured": false,
"mode": "cli"
}))?
);
return Ok(());
};
let backend = CodexCliBackend::new(cli)?;
let cli = backend.config();
// 不回显完整 argv;即使白名单校验通过,也不把潜在的
// 业务参数或可疑的可执行路径复制到 doctor/日志输出。
println!(
"{}",
serde_json::to_string_pretty(&json!({
"configured": true,
"mode": "cli",
"program": redacted_program_name(&cli.program),
"arg_count": cli.args.len(),
"timeout_ms": cli.timeout_ms,
"max_output_bytes": cli.max_output_bytes
}))?
);
}
other => return Err(format!("未知 codex 子命令: {other}(支持 validate").into()),
}
}
"doctor" => {
let (report, failed) = doctor_report(&db, &config)?;
println!("{}", serde_json::to_string_pretty(&report)?);
if failed {
return Err("doctor 检查失败".into());
}
}
"help" | "--help" | "-h" => print_help(),
other => {
// 兼容 README 中的简写:首个参数不是已知命令时,把整行当作任务。
let mut words = vec![other.to_owned()];
words.extend(args);
let task = words.join(" ");
let host = open_configured_host(&db, &config)?;
let result =
host.run_with_messages(task.clone(), prompt_messages_with_config(&task, &config)?)?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
Ok(())
}
/// 打开一个带有可选 Provider、Skill 和 MCP 配置的通用 Host。
///
/// 环境变量覆盖 `agent.toml`,但两者都只保存引用和普通配置;运行时密钥
/// 始终从环境读取,不写入 SQLite。
fn open_configured_host(
db: &Path,
config: &AgentTomlConfig,
) -> Result<AgentHost, Box<dyn std::error::Error>> {
configure_host(AgentHost::open(db)?, config)
}
/// CLI worker 和 App Server 共用装配过程;后者复用 control Host 的 Runtime
/// 不为每次请求创建另一套存储,也不重复使用已消费完的 Fake Provider 脚本。
fn configure_host(
host: AgentHost,
config: &AgentTomlConfig,
) -> Result<AgentHost, Box<dyn std::error::Error>> {
configure_host_with_fake_call_id(host, config, None)
}
fn configure_host_with_fake_call_id(
mut host: AgentHost,
config: &AgentTomlConfig,
fake_call_id: Option<&str>,
) -> Result<AgentHost, Box<dyn std::error::Error>> {
match config.provider().as_str() {
"fake" => {
let call_id = fake_call_id.unwrap_or("echo-call-1");
host = host.with_provider(
Arc::new(agent_provider_fake::FakeProvider::tool_then_text(
call_id,
"echo",
json!({"text": "hello from fake provider"}),
"fake provider complete",
)),
config.model(),
)
}
"openai" => {
let model = config.model();
let model = if model == "fake" {
"gpt-4.1-mini".to_owned()
} else {
model
};
host = host.with_openai_config(config.openai_provider_config(), model)?;
}
other => return Err(format!("未知 provider: {other}(仅支持 fake/openai").into()),
}
if let Some((loader, names)) = skill_config_from_config(config)? {
host = host.with_skills_from_loader(&loader, names)?;
}
let mcp_server = mcp_config_from_config(config)?;
let mcp_name = mcp_server.as_ref().map(|config| config.name.clone());
let mcp_context = mcp_context_selection_from_config(config)?;
if let Some(server_config) = mcp_server {
let timeout = mcp_timeout_from_config(config.mcp.timeout_secs)?;
let options = McpClientOptions::default().with_timeout(timeout);
host = if mcp_context.is_empty() {
host.with_mcp_server(&server_config, options)?
} else {
host.with_mcp_server_and_context(&server_config, options, mcp_context)?
};
} else if !mcp_context.is_empty() {
return Err(
"设置 MCP context_resources/context_prompts 前需要设置 MCP transport 配置".into(),
);
}
let allowed = non_empty_env("AGENT_MCP_ALLOW")
.map(|value| split_names(&value))
.unwrap_or_else(|| config.mcp.allow.clone());
if !allowed.is_empty() {
let server = mcp_name.ok_or(
"设置 AGENT_MCP_ALLOW 前需要设置 AGENT_MCP_STDIO_COMMAND 或 AGENT_MCP_HTTP_URL",
)?;
let mut names = vec!["echo".to_owned()];
names.extend(allowed.into_iter().map(|name| {
if name.starts_with("mcp:") {
name
} else {
format!("mcp:{server}:{name}")
}
}));
host = host.with_approval(Arc::new(AllowList::new(names)));
}
Ok(host)
}
/// 只做本地配置检查。doctor 不会连接 MCP/Provider,也不会启动 Codex
/// 但会通过 `AgentHost::open` 打开并按需初始化/迁移本地 SQLite/WAL;它只验证
/// 真正执行路径会使用的配置边界,并把每个组件的结果单独输出,避免 SQLite
/// 正常却掩盖其它配置错误。
fn doctor_report(
db: &Path,
config: &AgentTomlConfig,
) -> Result<(Value, bool), Box<dyn std::error::Error>> {
let host = AgentHost::open(db)?;
let journal_mode = host.journal_mode()?;
let mut failures = Vec::new();
let mut checks = serde_json::Map::new();
checks.insert(
"database".to_owned(),
json!({
"status": "ok",
"journal_mode": journal_mode,
}),
);
checks.insert(
"provider".to_owned(),
doctor_component("provider", doctor_provider_check(config), &mut failures),
);
checks.insert(
"skills".to_owned(),
doctor_component("skills", doctor_skills_check(config), &mut failures),
);
checks.insert(
"mcp".to_owned(),
doctor_component("mcp", doctor_mcp_check(config), &mut failures),
);
checks.insert(
"codex".to_owned(),
doctor_component("codex", doctor_codex_check(config), &mut failures),
);
let failed = !failures.is_empty();
let status = if failed { "error" } else { "ok" };
Ok((
json!({
"status": status,
"checks": checks,
"errors": failures,
"config": env::var_os("AGENT_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("agent.toml")),
}),
failed,
))
}
fn doctor_component(
name: &str,
result: Result<Value, String>,
failures: &mut Vec<String>,
) -> Value {
match result {
Ok(mut value) => {
if let Value::Object(object) = &mut value {
object.insert("status".to_owned(), Value::String("ok".to_owned()));
}
value
}
Err(error) => {
let message = format!("{name}: {error}");
failures.push(message.clone());
json!({"status": "error", "error": message})
}
}
}
fn doctor_provider_check(config: &AgentTomlConfig) -> Result<Value, String> {
let provider = config.provider();
match provider.as_str() {
"fake" => {
let model = config.model();
if model.trim().is_empty() {
return Err("Fake Provider model 不能为空".to_owned());
}
Ok(json!({"provider": provider, "model": model}))
}
"openai" => {
let key_env = config.openai_api_key_env();
if key_env.trim().is_empty() {
return Err("OpenAI API key 环境变量名不能为空".to_owned());
}
let endpoint = config
.openai_provider_config()
.resolve_endpoint()
.map_err(|error| error.to_string())?;
if !env::var(&key_env)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Err(format!("OpenAI API key 环境变量不可用: {key_env}"));
}
let model = effective_model(config, &provider);
if model.trim().is_empty() {
return Err("OpenAI model 不能为空".to_owned());
}
// endpoint 只用于确认 URL 形状;可能包含网关路由信息,不在诊断
// 输出中回显原文。
let _ = endpoint;
Ok(json!({
"provider": provider,
"model": model,
"api_key_env": key_env,
"api_key_available": true,
"endpoint_configured": true,
}))
}
other => Err(format!("未知 provider: {other}(仅支持 fake/openai")),
}
}
fn doctor_skills_check(config: &AgentTomlConfig) -> Result<Value, String> {
let Some((loader, names)) =
skill_config_from_config(config).map_err(|error| error.to_string())?
else {
return Ok(json!({"configured": false, "requested": 0, "discovered": 0}));
};
let definitions = loader
.list_definitions()
.map_err(|error| error.to_string())?;
let missing = names
.iter()
.filter(|name| {
!definitions
.iter()
.any(|definition| definition.name() == name.as_str())
})
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!("未找到显式激活的 Skill: {}", missing.join(", ")));
}
Ok(json!({
"configured": true,
"root_count": loader.roots().len(),
"requested": names.len(),
"discovered": definitions.len(),
}))
}
fn doctor_mcp_check(config: &AgentTomlConfig) -> Result<Value, String> {
let context = mcp_context_selection_from_config(config).map_err(|error| error.to_string())?;
let Some(server) = mcp_config_from_config(config).map_err(|error| error.to_string())? else {
if !context.is_empty() {
return Err(
"设置 MCP context_resources/context_prompts 前需要设置 MCP transport 配置"
.to_owned(),
);
}
return Ok(json!({"configured": false, "auth_references": 0}));
};
if server.name.trim().is_empty() {
return Err("MCP server name 不能为空".to_owned());
}
let transport_name = match &server.transport {
McpTransportConfig::Stdio { command, .. } => {
if command.trim().is_empty() {
return Err("MCP stdio command 不能为空".to_owned());
}
if command.chars().any(char::is_control) {
return Err("MCP stdio command 不能包含控制字符".to_owned());
}
"stdio"
}
McpTransportConfig::StreamableHttp { url, headers } => {
if url.trim().is_empty() {
return Err("MCP HTTP URL 不能为空".to_owned());
}
let mut names = BTreeMap::<String, ()>::new();
for name in headers.keys() {
let normalized = name.to_ascii_lowercase();
if names.insert(normalized, ()).is_some() {
return Err("MCP HTTP headers 不能包含大小写重复的字段".to_owned());
}
}
"streamable_http"
}
};
for auth in &server.auth {
if auth.variable.trim().is_empty() {
return Err("MCP 认证环境变量名不能为空".to_owned());
}
if !env::var(&auth.variable)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Err(format!("MCP 认证环境变量不可用: {}", auth.variable));
}
let target_matches = matches!(
(&server.transport, &auth.target),
(
McpTransportConfig::StreamableHttp { .. },
agent_mcp::McpAuthTarget::HttpBearer | agent_mcp::McpAuthTarget::HttpHeader { .. }
) | (
McpTransportConfig::Stdio { .. },
agent_mcp::McpAuthTarget::StdioEnvironment { .. }
)
);
if !target_matches {
return Err("MCP 认证 target 与 transport 不匹配".to_owned());
}
}
Ok(json!({
"configured": true,
"server": server.name,
"transport": transport_name,
"auth_references": server.auth.len(),
"context_resources": context.resource_uris().len(),
"context_prompts": context.prompts().len(),
}))
}
fn doctor_codex_check(config: &AgentTomlConfig) -> Result<Value, String> {
let Some(cli) = config.codex.cli.clone() else {
return Ok(json!({"configured": false, "mode": "cli"}));
};
let backend = CodexCliBackend::new(cli).map_err(|error| error.to_string())?;
let cli = backend.config();
Ok(json!({
"configured": true,
"mode": "cli",
"program": redacted_program_name(&cli.program),
"arg_count": cli.args.len(),
"timeout_ms": cli.timeout_ms,
"max_output_bytes": cli.max_output_bytes,
}))
}
fn redacted_program_name(_program: &str) -> &'static str {
// Even a basename can contain an inline token (for example a generated
// wrapper name), so diagnostics expose only that a program was configured.
"<configured>"
}
/// 从配置构造确定性 Prompt;环境覆盖 TOML,每个 section 保持独立边界。
fn prompt_messages_with_config(
task: &str,
config: &AgentTomlConfig,
) -> Result<Vec<Message>, Box<dyn std::error::Error>> {
let mut prompt = PromptBuilder::new();
if let Some(value) =
non_empty_env("AGENT_SYSTEM_PROMPT").or_else(|| config.system_prompt.clone())
{
prompt = prompt.system(value)?;
}
if let Some(value) =
non_empty_env("AGENT_DEVELOPER_PROMPT").or_else(|| config.developer_prompt.clone())
{
prompt = prompt.developer(value)?;
}
if let Some(value) =
non_empty_env("AGENT_CONTEXT_PROMPT").or_else(|| config.context_prompt.clone())
{
// Context section 在旧 MessageRole 合同中通过 user 通道发送,
// 但 section 类型仍保留,Provider 不会把它误当成 system 约束。
prompt = prompt.context(value)?;
}
prompt = prompt.user(task.to_owned())?;
Ok(prompt.build()?)
}
type ConfiguredSkills = (SkillLoader, Vec<String>);
fn skill_config_from_config(
config: &AgentTomlConfig,
) -> Result<Option<ConfiguredSkills>, Box<dyn std::error::Error>> {
let names = non_empty_env("AGENT_SKILLS")
.map(|value| split_names(&value))
.unwrap_or_else(|| config.skills.names.clone());
let roots = non_empty_env("AGENT_SKILL_ROOTS")
.or_else(|| non_empty_env("AGENT_SKILL_ROOT"))
.map(|value| {
value
.split(':')
.filter(|root| !root.trim().is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>()
})
.unwrap_or_else(|| config.skills.roots.iter().map(PathBuf::from).collect());
match (roots, names) {
(roots, names) if roots.is_empty() && names.is_empty() => Ok(None),
(roots, names) if roots.is_empty() && !names.is_empty() => {
Err("AGENT_SKILLS 已设置,但缺少 AGENT_SKILL_ROOT/AGENT_SKILL_ROOTS".into())
}
(roots, names) if !roots.is_empty() && names.is_empty() => {
Err("AGENT_SKILL_ROOT 已设置,但缺少显式 AGENT_SKILLS 名称".into())
}
(roots, names) => Ok(Some((SkillLoader::with_roots(roots), names))),
}
}
fn mcp_config_from_config(
config: &AgentTomlConfig,
) -> Result<Option<McpServerConfig>, Box<dyn std::error::Error>> {
let stdio_args = non_empty_env("AGENT_MCP_STDIO_ARGS").or_else(|| {
(!config.mcp.stdio_args.is_empty())
.then(|| serde_json::to_string(&config.mcp.stdio_args).expect("字符串数组可序列化"))
});
// 旧的 `AGENT_MCP_HTTP_HEADERS` 仍可作为进程环境中的兼容入口;TOML
// 不再接受明文 header map,只能通过 `[[mcp.auth]]` 引用环境变量。
let http_headers = non_empty_env("AGENT_MCP_HTTP_HEADERS");
let auth = config
.mcp
.auth
.clone()
.into_iter()
.map(McpAuthToml::into_core)
.collect::<Result<Vec<_>, _>>()?;
mcp_config_from_values_with_auth(
non_empty_env("AGENT_MCP_STDIO_COMMAND").or_else(|| config.mcp.stdio_command.clone()),
stdio_args,
non_empty_env("AGENT_MCP_HTTP_URL").or_else(|| config.mcp.http_url.clone()),
http_headers,
non_empty_env("AGENT_MCP_SERVER").or_else(|| config.mcp.server.clone()),
auth,
)
}
/// 读取显式 MCP context 选择;默认不读取任何 resource/prompt。
/// 环境变量使用与工具 allow list 相同的逗号/空白分隔形式,TOML 则保留
/// 每个 URI/名称作为一个字符串。prompt 参数需要更丰富的形状时请使用
/// `agent-host::McpContextSelection` 库 API,而不是在 CLI 中猜测 wire。
fn mcp_context_selection_from_config(
config: &AgentTomlConfig,
) -> Result<McpContextSelection, Box<dyn std::error::Error>> {
let resources = non_empty_env("AGENT_MCP_CONTEXT_RESOURCES")
.map(|value| split_names(&value))
.unwrap_or_else(|| config.mcp.context_resources.clone());
let prompts = non_empty_env("AGENT_MCP_CONTEXT_PROMPTS")
.map(|value| split_names(&value))
.unwrap_or_else(|| config.mcp.context_prompts.clone());
let mut selection = McpContextSelection::new();
for uri in resources {
selection = selection.with_resource_uri(uri);
}
for name in prompts {
selection = selection.with_prompt(name);
}
Ok(selection)
}
/// 纯配置组装函数;把环境读取留在上一层,测试和嵌入宿主可以不改全局环境
/// 就验证 transport 互斥、参数归属和认证头解析。
#[cfg(test)]
fn mcp_config_from_values(
command: Option<String>,
stdio_args: Option<String>,
url: Option<String>,
http_headers: Option<String>,
configured_server: Option<String>,
) -> Result<Option<McpServerConfig>, Box<dyn std::error::Error>> {
mcp_config_from_values_with_auth(
command,
stdio_args,
url,
http_headers,
configured_server,
Vec::new(),
)
}
/// 组装 MCP 配置并保留认证环境变量引用;这里绝不解析或复制 secret。
fn mcp_config_from_values_with_auth(
command: Option<String>,
stdio_args: Option<String>,
url: Option<String>,
http_headers: Option<String>,
configured_server: Option<String>,
auth: Vec<McpAuthEnv>,
) -> Result<Option<McpServerConfig>, Box<dyn std::error::Error>> {
if command.is_some() && url.is_some() {
return Err("AGENT_MCP_STDIO_COMMAND 与 AGENT_MCP_HTTP_URL 只能配置一个".into());
}
if command.is_none() && stdio_args.is_some() {
return Err("设置 AGENT_MCP_STDIO_ARGS 前需要设置 AGENT_MCP_STDIO_COMMAND".into());
}
if url.is_none() && http_headers.is_some() {
return Err("设置 AGENT_MCP_HTTP_HEADERS 前需要设置 AGENT_MCP_HTTP_URL".into());
}
if command.is_none() && url.is_none() && !auth.is_empty() {
return Err("设置 MCP 认证引用前需要设置 MCP transport 配置".into());
}
let Some(server_name) = configured_server
.or_else(|| (command.is_some() || url.is_some()).then(|| "default".to_owned()))
else {
return Ok(None);
};
let transport = if let Some(command) = command {
let args = stdio_args
.map(|value| parse_stdio_args(&value))
.transpose()?
.unwrap_or_default();
McpTransportConfig::stdio(command, args)
} else if let Some(url) = url {
let mut transport = McpTransportConfig::streamable_http(url);
if let Some(raw_headers) = http_headers {
let headers = serde_json::from_str::<BTreeMap<String, String>>(&raw_headers).map_err(
|error| format!("AGENT_MCP_HTTP_HEADERS 必须是 JSON 字符串对象: {error}"),
)?;
if let McpTransportConfig::StreamableHttp {
headers: configured,
..
} = &mut transport
{
*configured = headers;
}
}
transport
} else {
return Err("AGENT_MCP_SERVER 已设置,但缺少 MCP transport 配置".into());
};
let mut server = McpServerConfig::new(server_name, transport);
server.auth = auth;
Ok(Some(server))
}
/// JSON 数组可精确保留空格;简短命令仍可使用无 shell 展开的空白分隔形式。
fn parse_stdio_args(value: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
if value.trim_start().starts_with('[') {
return serde_json::from_str::<Vec<String>>(value)
.map_err(|error| format!("AGENT_MCP_STDIO_ARGS JSON 数组无效: {error}").into());
}
Ok(value.split_whitespace().map(str::to_owned).collect())
}
fn mcp_timeout_from_config(
configured_seconds: Option<u64>,
) -> Result<Duration, Box<dyn std::error::Error>> {
let seconds = non_empty_env("AGENT_MCP_TIMEOUT_SECS")
.map(|value| {
value
.parse::<u64>()
.map_err(|error| format!("AGENT_MCP_TIMEOUT_SECS 无效: {error}"))
})
.transpose()?
.or(configured_seconds)
.unwrap_or(30);
if seconds == 0 {
return Err("AGENT_MCP_TIMEOUT_SECS 必须大于 0".into());
}
Ok(Duration::from_secs(seconds))
}
fn non_empty_env(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
/// `run` 的选项解析保持无依赖、无 shell 语义;未知参数仍作为任务文本保留。
/// 这样脚本可以把 `--jsonl` 放在任务前后,而不会改变任务中的其它词。
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct RunOptions {
background: bool,
jsonl: bool,
streaming: Option<bool>,
}
fn parse_run_options(args: Vec<String>) -> Result<(RunOptions, Vec<String>), String> {
let mut options = RunOptions::default();
let mut task = Vec::new();
for arg in args {
match arg.as_str() {
"--background" => options.background = true,
"--jsonl" => options.jsonl = true,
"--stream" => {
if options.streaming == Some(false) {
return Err("--stream 与 --no-stream 不能同时使用".to_owned());
}
options.streaming = Some(true);
}
"--no-stream" => {
if options.streaming == Some(true) {
return Err("--stream 与 --no-stream 不能同时使用".to_owned());
}
options.streaming = Some(false);
}
_ => task.push(arg),
}
}
Ok((options, task))
}
/// 将一次同步 Host 结果编码为稳定的 NDJSON 记录。
///
/// `engine_event` 和 `stream_event` 记录保留各自的事件类型;最后的
/// `result` 记录携带原来的完整 `HostRunOutput`,因此已有 JSON 消费者可以
/// 只读取最后一行,而需要增量审计的脚本可以逐行处理前面的事件。
fn jsonl_run_records(result: &HostRunOutput) -> Result<Vec<serde_json::Value>, serde_json::Error> {
let identity = |record_type: &str| {
json!({
"type": record_type,
"session_id": result.session_id,
"run_id": result.run_id,
"runtime_id": result.runtime_id,
})
};
let mut records =
Vec::with_capacity(result.output.events.len() + result.output.stream_events.len() + 1);
for event in &result.output.events {
let mut record = identity("engine_event");
record["event"] = serde_json::to_value(event)?;
records.push(record);
}
for event in &result.output.stream_events {
let mut record = identity("stream_event");
record["event"] = serde_json::to_value(event)?;
records.push(record);
}
let mut final_record = identity("result");
final_record["result"] = serde_json::to_value(result)?;
records.push(final_record);
Ok(records)
}
fn print_run_result(result: &HostRunOutput, jsonl: bool) -> Result<(), Box<dyn std::error::Error>> {
if !jsonl {
println!("{}", serde_json::to_string_pretty(result)?);
return Ok(());
}
for record in jsonl_run_records(result)? {
println!("{}", serde_json::to_string(&record)?);
}
Ok(())
}
/// 把 Host 返回的审批记录转换成 CLI 展示视图。
///
/// `ApprovalRecord.approval_token` 以及持久化在 `request` 里的
/// `approvalToken` 都是 Host 恢复时校验 binding 所需的内部值,不能随
/// `approval list/get/allow/deny` 回显。这里仅在输出边界复制 JSON 并移除
/// token 字段,Host 内部仍持有完整记录,故不会影响 `approval resume`。
fn redacted_approval_view<T: Serialize>(record: &T) -> Result<Value, serde_json::Error> {
let mut value = serde_json::to_value(record)?;
redact_approval_tokens(&mut value);
Ok(value)
}
/// 当前记录本身使用 snake_case、嵌套 Core request 使用 camelCase;递归移除
/// 两种字段名可覆盖这两个持久化层次,同时保留审批请求中的工具参数等展示信息。
fn redact_approval_tokens(value: &mut Value) {
match value {
Value::Object(object) => {
object.remove("approval_token");
object.remove("approvalToken");
for child in object.values_mut() {
redact_approval_tokens(child);
}
}
Value::Array(values) => {
for child in values {
redact_approval_tokens(child);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn print_queued_result(
handle: &HostRunHandle,
worker_pid: u32,
jsonl: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let record = json!({
"type": "queued",
"status": "queued",
"worker_pid": worker_pid,
"session_id": handle.session_id,
"run_id": handle.run_id,
"runtime_id": handle.runtime_id,
});
if jsonl {
println!("{}", serde_json::to_string(&record)?);
} else {
println!("{}", serde_json::to_string_pretty(&record)?);
}
Ok(())
}
fn split_names(value: &str) -> Vec<String> {
value
.split([',', ' ', '\n', '\t'])
.filter(|name| !name.trim().is_empty())
.map(|name| name.trim().to_owned())
.collect()
}
/// 读取外部对账方提交的完整 Core 消息历史。支持文件路径、`-` stdin,
/// 以及直接传入以 `[` 开头的 JSON,方便脚本在不落盘时调用。
fn read_messages_arg(value: &str) -> Result<Vec<Message>, Box<dyn std::error::Error>> {
let raw = if value == "-" {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
input
} else if value.trim_start().starts_with('[') {
value.to_owned()
} else {
fs::read_to_string(value)?
};
serde_json::from_str::<Vec<Message>>(&raw)
.map_err(|error| format!("对账消息必须是 Core Message JSON 数组: {error}").into())
}
/// 启动后台 worker;如果子进程根本没有成功创建,则只尝试把仍未领取的
/// durable run 收束为 failed。`fail_unclaimed_run` 自身会原子检查 status、
/// cancel_requested 和 lease,因此不会覆盖已经被其它 worker 领取的 run。
fn spawn_worker_or_fail_unclaimed(
host: &AgentHost,
db: &Path,
run_id: &str,
streaming: bool,
) -> Result<u32, Box<dyn std::error::Error>> {
spawn_worker_or_fail_unclaimed_with(
run_id,
|| spawn_worker(db, run_id, streaming),
|reason| {
host.fail_unclaimed_run(run_id, reason)
.map(|_| ())
.map_err(Into::into)
},
)
}
/// `spawn_worker_or_fail_unclaimed` 的可注入内核,供 CLI 单测覆盖子进程
/// 创建失败和 durable 收口失败,而无需真的 fork 当前可执行文件。
fn spawn_worker_or_fail_unclaimed_with<S, C>(
run_id: &str,
spawn: S,
fail_unclaimed: C,
) -> Result<u32, Box<dyn std::error::Error>>
where
S: FnOnce() -> Result<u32, Box<dyn std::error::Error>>,
C: FnOnce(&str) -> Result<(), Box<dyn std::error::Error>>,
{
match spawn() {
Ok(pid) => Ok(pid),
Err(spawn_error) => {
let reason = format!("worker 启动失败: {spawn_error}");
match fail_unclaimed(&reason) {
Ok(()) => {
Err(format!("{reason}run {run_id} 已标记 failed,未启动 worker").into())
}
Err(control_error) => {
Err(format!("{reason}run {run_id} 未能自动收口: {control_error}").into())
}
}
}
}
}
fn spawn_worker(
db: &Path,
run_id: &str,
streaming: bool,
) -> Result<u32, Box<dyn std::error::Error>> {
let executable = env::current_exe()?;
let child = Command::new(executable)
.arg("worker")
.arg(run_id)
.env("AGENT_DB", db)
.env("AGENT_STREAM_WORKER", if streaming { "1" } else { "0" })
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(child.id())
}
fn skill_roots_from_config(config: &AgentTomlConfig) -> Vec<PathBuf> {
non_empty_env("AGENT_SKILL_ROOTS")
.or_else(|| non_empty_env("AGENT_SKILL_ROOT"))
.map(|value| {
value
.split(':')
.filter(|root| !root.trim().is_empty())
.map(PathBuf::from)
.collect()
})
.unwrap_or_else(|| config.skills.roots.iter().map(PathBuf::from).collect())
}
fn list_skills(
config: &AgentTomlConfig,
) -> Result<Vec<agent_runtime_core::SkillDefinition>, Box<dyn std::error::Error>> {
let roots = skill_roots_from_config(config);
if roots.is_empty() {
return Ok(Vec::new());
}
Ok(SkillLoader::with_roots(roots).list_definitions()?)
}
fn list_mcp(config: &AgentTomlConfig) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let server = mcp_config_from_config(config)?
.ok_or("未配置 MCP;请设置 agent.toml 的 [mcp] 或 AGENT_MCP_* 环境变量")?;
let mut client = agent_mcp::McpClient::connect(
&server,
McpClientOptions::default().with_timeout(mcp_timeout_from_config(config.mcp.timeout_secs)?),
)?;
mcp_list_from_client(&server.name, &mut client)
}
/// 从同一个能力快照生成 `mcp list` 输出。
///
/// 保持这个小 helper 独立于连接装配,既让 CLI 的真实路径只握手一次,
/// 也让离线 transport 回归能验证展示内容和指纹来自同一批目录请求。
fn mcp_list_from_client(
server_name: &str,
client: &mut agent_mcp::McpClient,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
// Use one capability snapshot for both displayed tools and its fingerprint.
// Calling list_tools first and capability_fingerprint second would issue a
// second tools/list request, allowing a changing server to produce a
// fingerprint that does not describe the tools shown to the user. Errors
// stay visible instead of being converted into a misleading null hash.
let snapshot = client.capability_snapshot()?;
let capability_fingerprint = snapshot.fingerprint()?;
Ok(json!({
"server": server_name,
"tools": snapshot.tools,
"capability_fingerprint": capability_fingerprint,
}))
}
fn print_help() {
println!(
r#"用法:
agent app-server --stdio # 独立 JSON-RPC 服务;无需 Codex
agent run [--stream|--no-stream] [--jsonl] [任务]
agent run --background [--jsonl] [任务]
agent worker <run_id> # 内部 worker
agent cancel <run_id>
agent approval list <run_id>
agent approval get <approval_id>
agent approval allow <approval_id>
agent approval deny <approval_id> <原因>
agent approval resume <approval_id> # resolve 后显式启动 worker
agent resume <run_id> # 仅启动 queued run
agent resume-safe <run_id> # 外部对账后只从 safe checkpoint 继续
agent reconcile <run_id> # 过期 running -> reconciling,不重放
agent reconcile --stale [limit] # 有界扫描失去 lease 的 run,默认最多 256 项
agent reconcile-provider <run_id> <provider_request_id> <messages.json|-> # 写入已核对 Provider 响应
agent reconcile-tool <run_id> <tool_call_id> <messages.json|-> # 写入已核对工具结果
agent checkpoint <run_id> # 查看最近边界检查点,不改变状态
agent inspect <run_id>
agent export <run_id>
agent skills list
agent mcp list
agent codex validate # 校验 agent.toml 中受限 Codex CLI 配置,不启动进程
agent doctor # 不联网/不启动外部进程;会打开并按需初始化本地 SQLite/WAL
基础配置:AGENT_CONFIG(默认 agent.toml)、AGENT_DB、AGENT_PROVIDER、AGENT_MODEL、OPENAI_MODEL、AGENT_STREAM、OPENAI_API_KEY、OPENAI_API_KEY_ENV、AGENT_OPENAI_API_KEY_ENV、OPENAI_BASE_URL、OPENAI_ENDPOINT、AGENT_SYSTEM_PROMPT、AGENT_DEVELOPER_PROMPT、AGENT_CONTEXT_PROMPT。
OpenAI endpointOPENAI_ENDPOINT 是完整请求地址;否则使用 OPENAI_BASE_URL 并自动补 /responses。TOML 可写 openai_endpoint 或 openai_base_url;环境变量优先于 TOML。TOML 示例:provider = "fake"model = "fake"stream = truedb = "agent.db"openai_api_key_env = "OPENAI_API_KEY"openai_base_url = "https://gateway.example/v1"[skills] roots = [".codex/skills"] names = ["review"]。
SkillAGENT_SKILL_ROOT(或 AGENT_SKILL_ROOTS,冒号分隔)+ AGENT_SKILLS(逗号/空白分隔,必须显式列名)。
MCPAGENT_MCP_STDIO_COMMAND + AGENT_MCP_STDIO_ARGS,或 AGENT_MCP_HTTP_URL;认证使用 [[mcp.auth]] variable/target/name/prefix 引用环境变量(不写 token 原文),兼容入口 AGENT_MCP_HTTP_HEADERSJSON 字符串对象);可选 AGENT_MCP_SERVER、AGENT_MCP_TIMEOUT_SECS、AGENT_MCP_CONTEXT_RESOURCES、AGENT_MCP_CONTEXT_PROMPTS。context 只读取显式列出的 URI/名称,并作为不可信内容注入。
MCP 工具默认拒绝,设置 AGENT_MCP_ALLOW(原名或 mcp:server:name,逗号/空白分隔)后才放行;命令和参数不会经过 shell 展开。
Codex`[codex.cli]` 可配置 program/args/timeout_ms/max_output_bytes/allowed_arg_prefixes`codex validate` 只做本地白名单校验,不启动进程、不写入运行状态。App Server 仍通过 `agent-codex` 的 channel API 注入。
取消是 cooperative:当前 Provider/工具调用返回后在下一个 step 生效。worker lease 过期后必须先对账,CLI 不会自动重放外部调用;reconcile-provider/reconcile-tool 只接受完整消息历史并写 safe checkpoint,随后仍需 resume-safe。"#
);
}
#[cfg(test)]
mod tests {
use super::{
AgentTomlConfig, DEFAULT_STALE_RECONCILE_LIMIT, HostRunOutput, McpAuthToml, ReconcileArgs,
doctor_mcp_check, jsonl_run_records, mcp_config_from_values,
mcp_config_from_values_with_auth, mcp_context_selection_from_config, mcp_list_from_client,
parse_reconcile_args, parse_run_options, parse_stdio_args, read_messages_arg,
redacted_approval_view, spawn_worker_or_fail_unclaimed_with,
};
use agent_codex::CodexCliBackend;
use agent_mcp::{
DEFAULT_PROTOCOL_VERSION, JsonRpcRequest, JsonRpcResponse, McpAuthEnv, McpAuthTarget,
McpCapabilitySnapshot, McpError, McpSyncTransport, McpToolDefinition, McpTransportConfig,
};
use agent_runtime_engine::{AgentOutput, EngineEvent};
use serde_json::json;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[test]
fn blank_toml_model_is_treated_as_unset() {
let config: AgentTomlConfig =
toml::from_str("provider = 'openai'\nmodel = ' '").expect("配置应可解析");
assert_eq!(config.model(), "fake");
assert_eq!(super::effective_model(&config, "openai"), "gpt-4.1-mini");
}
#[test]
fn background_queue_metadata_uses_configured_provider_and_effective_model() {
let config: AgentTomlConfig =
toml::from_str("provider = 'openai'\nmodel = ' '\n").expect("配置应可解析");
let metadata = super::queued_run_metadata(&config, "openai");
assert_eq!(metadata["provider"], "gpt-4.1-mini");
assert_eq!(metadata["providerKind"], "openai");
}
#[test]
fn run_options_allow_jsonl_anywhere_and_reject_conflicting_stream_flags() {
let (options, task) = parse_run_options(vec![
"--jsonl".to_owned(),
"--stream".to_owned(),
"回答".to_owned(),
"--background".to_owned(),
])
.unwrap();
assert_eq!(
options,
super::RunOptions {
background: true,
jsonl: true,
streaming: Some(true),
}
);
assert_eq!(task, ["回答"]);
assert!(parse_run_options(vec!["--stream".to_owned(), "--no-stream".to_owned(),]).is_err());
}
#[test]
fn reconcile_args_keep_single_run_and_add_bounded_stale_batch() {
assert_eq!(
parse_reconcile_args(vec!["run-123".to_owned()]).unwrap(),
ReconcileArgs::Run("run-123".to_owned())
);
assert_eq!(
parse_reconcile_args(vec!["--stale".to_owned()]).unwrap(),
ReconcileArgs::Stale {
limit: DEFAULT_STALE_RECONCILE_LIMIT
}
);
assert_eq!(
parse_reconcile_args(vec!["--stale".to_owned(), "7".to_owned()]).unwrap(),
ReconcileArgs::Stale { limit: 7 }
);
assert!(parse_reconcile_args(Vec::new()).is_err());
assert!(parse_reconcile_args(vec!["--stale".to_owned(), "nope".to_owned()]).is_err());
assert!(
parse_reconcile_args(vec![
"--stale".to_owned(),
"1".to_owned(),
"extra".to_owned(),
])
.is_err()
);
}
#[test]
fn jsonl_records_are_one_line_and_end_with_complete_result() {
let result = HostRunOutput {
session_id: "session".to_owned(),
run_id: "run".to_owned(),
runtime_id: "runtime".to_owned(),
output: AgentOutput {
text: "done".to_owned(),
steps: 1,
events: vec![EngineEvent::Finished {
step: 0,
text: "done".to_owned(),
}],
stream_events: Vec::new(),
context_observations: Vec::new(),
messages: Vec::new(),
},
};
let records = jsonl_run_records(&result).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["type"], "engine_event");
assert_eq!(records[1]["type"], "result");
assert_eq!(records[1]["result"]["run_id"], "run");
for record in records {
let line = serde_json::to_string(&record).unwrap();
assert!(!line.contains('\n'));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&line).unwrap(),
record
);
}
}
#[test]
fn approval_cli_view_redacts_binding_tokens_but_keeps_request_details() {
// Storage records keep the binding token twice: once as the dedicated
// column and once inside the serialized Core request. The CLI view
// must hide both copies while leaving the fields users need to decide.
let record = serde_json::json!({
"id": "approval-1",
"status": "pending",
"approval_token": "approval-secret",
"request": {
"requestId": "approval-1",
"approvalToken": "approval-secret",
"call": {"id": "call-1", "name": "echo"},
"metadata": [{"approval_token": "nested-secret"}]
},
"decision": null
});
let view = redacted_approval_view(&record).unwrap();
let encoded = serde_json::to_string(&view).unwrap();
assert!(!encoded.contains("approval-secret"));
assert!(!encoded.contains("nested-secret"));
assert!(view.get("approval_token").is_none());
assert!(view["request"].get("approvalToken").is_none());
assert!(view["request"]["call"].get("name").is_some());
assert_eq!(view["status"], "pending");
}
#[test]
fn mcp_stdio_args_support_exact_json_array_and_simple_whitespace() {
assert_eq!(
parse_stdio_args(r#"["-c","print('hello world')"]"#).unwrap(),
["-c", "print('hello world')"]
);
assert_eq!(
parse_stdio_args("-y @example/server").unwrap(),
["-y", "@example/server"]
);
}
#[test]
fn mcp_config_rejects_stray_options_and_conflicting_transports() {
assert!(mcp_config_from_values(None, Some("-x".to_owned()), None, None, None,).is_err());
assert!(mcp_config_from_values(None, None, None, Some("{}".to_owned()), None,).is_err());
assert!(
mcp_config_from_values(
Some("stdio-server".to_owned()),
None,
Some("http://127.0.0.1:1".to_owned()),
None,
None,
)
.is_err()
);
assert!(
mcp_config_from_values(None, None, None, None, Some("configured-only".to_owned()),)
.is_err()
);
}
#[test]
fn mcp_config_builds_stdio_and_http_values_without_environment_mutation() {
let stdio = mcp_config_from_values(
Some("node".to_owned()),
Some(r#"["server.js","--stdio"]"#.to_owned()),
None,
None,
Some("tools".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(stdio.name, "tools");
match stdio.transport {
McpTransportConfig::Stdio { command, args, .. } => {
assert_eq!(command, "node");
assert_eq!(args, ["server.js", "--stdio"]);
}
_ => panic!("expected stdio transport"),
}
let http = mcp_config_from_values(
None,
None,
Some("http://127.0.0.1:4318/mcp".to_owned()),
Some(r#"{"Authorization":"Bearer test","X-Trace":"one"}"#.to_owned()),
None,
)
.unwrap()
.unwrap();
assert_eq!(http.name, "default");
match http.transport {
McpTransportConfig::StreamableHttp { url, headers } => {
assert_eq!(url, "http://127.0.0.1:4318/mcp");
assert_eq!(
headers.get("Authorization"),
Some(&"Bearer test".to_owned())
);
assert_eq!(headers.get("X-Trace"), Some(&"one".to_owned()));
}
_ => panic!("expected streamable HTTP transport"),
}
}
#[test]
fn mcp_list_uses_one_capability_snapshot_for_tools_and_fingerprint() {
struct CountingCatalog {
tools_requests: Arc<Mutex<usize>>,
}
impl McpSyncTransport for CountingCatalog {
fn send_request(
&mut self,
request: &JsonRpcRequest,
_timeout: Duration,
) -> Result<JsonRpcResponse, McpError> {
let result = match request.method.as_str() {
"initialize" => json!({
"protocolVersion": DEFAULT_PROTOCOL_VERSION,
"capabilities": {"tools": {}}
}),
"tools/list" => {
*self.tools_requests.lock().unwrap() += 1;
json!({
"tools": [{"name": "echo", "inputSchema": {"type": "object"}}]
})
}
other => return Err(McpError::Protocol(format!("unexpected method: {other}"))),
};
Ok(JsonRpcResponse {
jsonrpc: "2.0".to_owned(),
id: Some(request.id.clone()),
result: Some(result),
error: None,
})
}
fn send_notification(
&mut self,
_notification: &agent_mcp::JsonRpcNotification,
) -> Result<(), McpError> {
Ok(())
}
}
let tools_requests = Arc::new(Mutex::new(0));
let mut client = agent_mcp::McpClient::with_transport(
"catalog",
CountingCatalog {
tools_requests: tools_requests.clone(),
},
agent_mcp::McpClientOptions::default(),
);
let output = mcp_list_from_client("catalog", &mut client).unwrap();
assert_eq!(*tools_requests.lock().unwrap(), 1);
assert_eq!(output["tools"][0]["name"], "echo");
let expected = McpCapabilitySnapshot {
initialize: json!({
"protocolVersion": DEFAULT_PROTOCOL_VERSION,
"capabilities": {"tools": {}}
}),
tools: vec![McpToolDefinition::new("echo", json!({"type": "object"}))],
resources: Vec::new(),
prompts: Vec::new(),
};
assert_eq!(
output["capability_fingerprint"],
expected.fingerprint().unwrap()
);
}
#[test]
fn reconciliation_message_argument_accepts_inline_core_json() {
let messages =
read_messages_arg(r#"[{"role":"user","content":[{"type":"text","text":"hello"}]}]"#)
.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content()[0].as_text(), Some("hello"));
}
#[test]
fn toml_config_keeps_provider_and_secret_reference_separate() {
let config: AgentTomlConfig = toml::from_str(
r#"
db = "./tmp/agent.db"
provider = "openai"
model = "gpt-test"
openai_api_key_env = "TEAM_OPENAI_KEY"
openai_base_url = "https://gateway.example/v1"
system_prompt = "be concise"
[skills]
roots = [".codex/skills"]
names = ["review"]
[mcp]
server = "workspace"
stdio_command = "node"
stdio_args = ["server.js"]
timeout_secs = 9
allow = ["read"]
context_resources = ["file:///workspace/README.md"]
context_prompts = ["welcome"]
[[mcp.auth]]
variable = "TEAM_MCP_TOKEN"
target = "stdio_environment"
name = "MCP_TOKEN"
"#,
)
.unwrap();
assert_eq!(config.provider.as_deref(), Some("openai"));
assert_eq!(
config.openai_api_key_env.as_deref(),
Some("TEAM_OPENAI_KEY")
);
assert_eq!(
config.openai_base_url.as_deref(),
Some("https://gateway.example/v1")
);
assert_eq!(
config.openai_provider_config().resolve_endpoint().unwrap(),
"https://gateway.example/v1/responses"
);
assert_eq!(config.skills.names, ["review"]);
assert_eq!(config.mcp.timeout_secs, Some(9));
let context = mcp_context_selection_from_config(&config).unwrap();
assert_eq!(context.resource_uris(), ["file:///workspace/README.md"]);
assert_eq!(context.prompts().len(), 1);
assert_eq!(context.prompts()[0].name(), "welcome");
assert_eq!(config.mcp.auth.len(), 1);
assert_eq!(config.mcp.auth[0].variable, "TEAM_MCP_TOKEN");
assert_eq!(config.mcp.auth[0].target, "stdio_environment");
assert_eq!(config.mcp.auth[0].name.as_deref(), Some("MCP_TOKEN"));
// 序列化/调试只应携带环境变量名和目标,不应出现任何 token 原文。
let encoded = toml::to_string(&config).unwrap();
let debug = format!("{config:?}");
assert!(encoded.contains("TEAM_MCP_TOKEN"));
assert!(debug.contains("TEAM_MCP_TOKEN") || debug.contains("env-ref"));
for secret in ["plaintext-token", "Bearer plaintext-token"] {
assert!(!encoded.contains(secret));
assert!(!debug.contains(secret));
}
assert!(toml::from_str::<AgentTomlConfig>("api_key = 'plaintext'").is_err());
// 明文 TOML header map 不再是允许字段,避免 secret 落盘。
assert!(
toml::from_str::<AgentTomlConfig>(
"[mcp]\nhttp_headers = { Authorization = 'Bearer plaintext-token' }"
)
.is_err()
);
let core = config.mcp.auth[0].clone().into_core().unwrap();
assert!(matches!(
core.target,
McpAuthTarget::StdioEnvironment { name } if name == "MCP_TOKEN"
));
}
#[test]
fn openai_config_accepts_full_endpoint_without_secret_value() {
let config: AgentTomlConfig = toml::from_str(
r#"
provider = "openai"
openai_api_key_env = "TEAM_OPENAI_KEY"
openai_endpoint = "https://gateway.example/v1/responses"
"#,
)
.unwrap();
let provider_config = config.openai_provider_config();
assert_eq!(
provider_config.resolve_endpoint().unwrap(),
"https://gateway.example/v1/responses"
);
let encoded = toml::to_string(&config).unwrap();
assert!(encoded.contains("TEAM_OPENAI_KEY"));
assert!(!encoded.contains("secret-key"));
}
#[test]
fn openai_endpoint_precedence_is_environment_first_across_endpoint_forms() {
let config: AgentTomlConfig = toml::from_str(
r#"
provider = "openai"
openai_endpoint = "https://toml.example/v1/responses"
openai_base_url = "https://toml-base.example/v1"
"#,
)
.unwrap();
// A complete environment endpoint wins over every lower-priority
// source, including another environment base URL and both TOML forms.
assert_eq!(
config
.openai_provider_config_with_env(
Some("https://env.example/v1/responses"),
Some("https://env-base.example/v1"),
)
.resolve_endpoint()
.unwrap(),
"https://env.example/v1/responses"
);
// An environment base URL still overrides a TOML full endpoint; the
// source priority is evaluated before the full-vs-base preference.
assert_eq!(
config
.openai_provider_config_with_env(None, Some("https://env-base.example/v1"),)
.resolve_endpoint()
.unwrap(),
"https://env-base.example/v1/responses"
);
// With no environment override, TOML keeps its own full-endpoint
// precedence, then falls back to TOML base URL when needed.
assert_eq!(
config
.openai_provider_config_with_env(None, None)
.resolve_endpoint()
.unwrap(),
"https://toml.example/v1/responses"
);
let base_only: AgentTomlConfig = toml::from_str(
r#"
provider = "openai"
openai_base_url = "https://toml-base.example/v1"
"#,
)
.unwrap();
assert_eq!(
base_only
.openai_provider_config_with_env(None, None)
.resolve_endpoint()
.unwrap(),
"https://toml-base.example/v1/responses"
);
// Blank environment variables are treated as absent, matching the
// normal `non_empty_env` behavior used by the live CLI path.
assert_eq!(
base_only
.openai_provider_config_with_env(Some(" "), Some(""))
.resolve_endpoint()
.unwrap(),
"https://toml-base.example/v1/responses"
);
}
#[test]
fn mcp_context_requires_an_explicit_transport() {
let config: AgentTomlConfig = toml::from_str(
r#"
[mcp]
context_resources = ["file:///workspace/README.md"]
"#,
)
.unwrap();
let error = doctor_mcp_check(&config).unwrap_err();
assert!(error.contains("context_resources"));
assert!(error.contains("transport"));
}
#[test]
fn codex_cli_toml_is_validated_without_echoing_argv() {
let config: AgentTomlConfig = toml::from_str(
r#"
[codex.cli]
program = "codex"
args = ["--model=gpt-test"]
allowed_arg_prefixes = ["--model"]
timeout_ms = 5000
max_output_bytes = 4096
"#,
)
.unwrap();
let backend = CodexCliBackend::new(config.codex.cli.clone().unwrap()).unwrap();
assert_eq!(backend.config().timeout_ms, 5000);
assert_eq!(backend.config().max_output_bytes, 4096);
let encoded = serde_json::to_string(&config).unwrap();
assert!(encoded.contains("gpt-test"));
// CLI 的 validate 输出只会返回 arg_count,不应把完整 argv 当作
// 诊断日志;这里锁定配置本身可解析,具体输出由命令分支控制。
assert_eq!(config.codex.cli.unwrap().args.len(), 1);
}
#[test]
fn doctor_codex_view_does_not_echo_program_path() {
let config: AgentTomlConfig = toml::from_str(
r#"
[codex.cli]
program = "/tmp/generated-wrapper-with-token"
"#,
)
.unwrap();
let report = super::doctor_codex_check(&config).unwrap();
assert_eq!(report["program"], "<configured>");
assert!(
!serde_json::to_string(&report)
.unwrap()
.contains("generated-wrapper-with-token")
);
}
#[test]
fn doctor_mcp_check_rejects_empty_auth_reference_without_connecting() {
let config: AgentTomlConfig = toml::from_str(
r#"
[mcp]
server = "workspace"
http_url = "https://example.test/mcp"
[[mcp.auth]]
variable = ""
target = "http_bearer"
"#,
)
.unwrap();
let error = super::doctor_mcp_check(&config).unwrap_err();
assert!(error.contains("variable"));
}
#[test]
fn worker_spawn_failure_uses_injected_control_to_fail_unclaimed_run() {
let host = agent_host::AgentHost::in_memory().unwrap();
let handle = host.prepare_run("worker spawn failure").unwrap();
let run_id = handle.run_id.clone();
let mut control_calls = 0;
let error = spawn_worker_or_fail_unclaimed_with(
&run_id,
|| Err(std::io::Error::other("injected spawn failure").into()),
|reason| {
control_calls += 1;
host.fail_unclaimed_run(&run_id, reason)
.map(|_| ())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })
},
)
.unwrap_err();
assert_eq!(control_calls, 1);
assert!(error.to_string().contains("已标记 failed"));
assert_eq!(host.get_run(&run_id).unwrap().unwrap().status, "failed");
}
#[test]
fn worker_spawn_success_does_not_invoke_injected_failure_control() {
let mut control_called = false;
let pid = spawn_worker_or_fail_unclaimed_with(
"run-success",
|| Ok(4242),
|_| {
control_called = true;
Err(std::io::Error::other("control must not run").into())
},
)
.unwrap();
assert_eq!(pid, 4242);
assert!(!control_called);
}
#[test]
fn worker_spawn_failure_preserves_control_error() {
let error = spawn_worker_or_fail_unclaimed_with(
"run-control-error",
|| Err(std::io::Error::other("injected spawn failure").into()),
|_| Err(std::io::Error::other("lease already held").into()),
)
.unwrap_err()
.to_string();
assert!(error.contains("worker 启动失败"));
assert!(error.contains("未能自动收口"));
assert!(error.contains("lease already held"));
assert!(!error.contains("已标记 failed"));
}
#[test]
fn mcp_auth_reference_is_wired_without_resolving_secret() {
let reference = McpAuthEnv::http_bearer("TEAM_MCP_TOKEN");
let config = mcp_config_from_values_with_auth(
None,
None,
Some("https://example.test/mcp".to_owned()),
None,
Some("workspace".to_owned()),
vec![reference.clone()],
)
.unwrap()
.unwrap();
assert_eq!(config.name, "workspace");
assert_eq!(config.auth, vec![reference]);
let encoded = serde_json::to_string(&config).unwrap();
assert!(encoded.contains("TEAM_MCP_TOKEN"));
assert!(!encoded.contains("resolved-secret"));
let debug = format!("{config:?}");
assert!(!debug.contains("resolved-secret"));
assert!(debug.contains("env refs"));
}
#[test]
fn mcp_auth_reference_without_transport_is_rejected() {
let error = mcp_config_from_values_with_auth(
None,
None,
None,
None,
Some("workspace".to_owned()),
vec![McpAuthEnv::http_bearer("TEAM_MCP_TOKEN")],
)
.unwrap_err()
.to_string();
assert!(error.contains("transport"));
}
#[test]
fn mcp_toml_auth_targets_convert_without_secret_values() {
let cases = [
(
McpAuthToml {
variable: "MCP_BEARER".to_owned(),
target: "http_bearer".to_owned(),
name: None,
prefix: None,
},
McpAuthTarget::HttpBearer,
),
(
McpAuthToml {
variable: "MCP_HEADER".to_owned(),
target: "http_header".to_owned(),
name: Some("X-API-Key".to_owned()),
prefix: Some("Token ".to_owned()),
},
McpAuthTarget::HttpHeader {
name: "X-API-Key".to_owned(),
prefix: "Token ".to_owned(),
},
),
(
McpAuthToml {
variable: "MCP_CHILD".to_owned(),
target: "stdio_environment".to_owned(),
name: Some("CHILD_TOKEN".to_owned()),
prefix: None,
},
McpAuthTarget::StdioEnvironment {
name: "CHILD_TOKEN".to_owned(),
},
),
];
for (toml_auth, expected_target) in cases {
let variable = toml_auth.variable.clone();
let core = toml_auth.into_core().unwrap();
assert_eq!(core.variable, variable);
assert_eq!(core.target, expected_target);
}
}
}