36ae9425e9
支持使用 Fake Provider 验证跨进程 Agent 回合 修正独立 Agent 与排队模型的匹配和子进程超时回收 补充 AGC 到独立 App Server 的真实子进程 smoke
1945 lines
75 KiB
Rust
1945 lines
75 KiB
Rust
//! 面向人和脚本的最小 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 endpoint:OPENAI_ENDPOINT 是完整请求地址;否则使用 OPENAI_BASE_URL 并自动补 /responses。TOML 可写 openai_endpoint 或 openai_base_url;环境变量优先于 TOML。TOML 示例:provider = "fake";model = "fake";stream = true;db = "agent.db";openai_api_key_env = "OPENAI_API_KEY";openai_base_url = "https://gateway.example/v1";[skills] roots = [".codex/skills"] names = ["review"]。
|
||
Skill:AGENT_SKILL_ROOT(或 AGENT_SKILL_ROOTS,冒号分隔)+ AGENT_SKILLS(逗号/空白分隔,必须显式列名)。
|
||
MCP:AGENT_MCP_STDIO_COMMAND + AGENT_MCP_STDIO_ARGS,或 AGENT_MCP_HTTP_URL;认证使用 [[mcp.auth]] variable/target/name/prefix 引用环境变量(不写 token 原文),兼容入口 AGENT_MCP_HTTP_HEADERS(JSON 字符串对象);可选 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);
|
||
}
|
||
}
|
||
}
|