1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
//! AI Runtime — wires all 5 layers together for command use.
//!
//! Central integration point connecting L1 (Provider), L2 (Knowledge/Config),
//! and L3 (Agent/Tools) for L4 (Commands). Ensures scope config resolution,
//! tool registration, and skill/MCP discovery happen once at initialization.
use std::path::Path;
use anyhow::Result;
use colored::Colorize as _;
use oxo_flow_ai::agent::orchestrator::Orchestrator;
use oxo_flow_ai::config::AiConfig;
use oxo_flow_ai::provider::AiProvider;
use oxo_flow_ai::tools::{ToolRegistry, builtin};
/// Fully-configured AI runtime for a command execution.
pub struct AiRuntime {
pub provider: AiProvider,
pub config: AiConfig,
pub tool_registry: ToolRegistry,
pub orchestrator: Orchestrator,
/// Prompt context from activated user-defined skills.
pub skill_context: String,
}
impl AiRuntime {
/// Initialize AI runtime with full scope config resolution.
///
/// Resolution chain: env vars → global config → project .oxo-flow/ai.toml →
/// workflow `[ai]` section → CLI overrides.
pub async fn new(
workflow_path: Option<&Path>,
project_dir: Option<&Path>,
cli_max_retries: Option<u32>,
) -> Result<Self> {
let provider = super::ai_template::resolve_ai_provider()?;
// Resolve config chain
let global = AiConfig::from_global_file();
let project = project_dir.and_then(AiConfig::from_project_file);
let workflow = workflow_path.and_then(|p| {
std::fs::read_to_string(p).ok().and_then(|c| {
c.parse::<toml::Table>()
.ok()
.and_then(|t| AiConfig::from_workflow_toml(&t))
})
});
let cli_overrides = cli_max_retries.map(|n| AiConfig {
max_retries: n,
..Default::default()
});
let config = AiConfig::resolve_chain(
global.as_ref(),
project.as_ref(),
workflow.as_ref(),
None,
cli_overrides.as_ref(),
);
// User-defined skills: discovery (read-only) + explicit activation.
let project = project_dir.or_else(|| workflow_path.and_then(|p| p.parent()));
// Register tools: builtins + discovered skills. read_file is scoped
// to the project directory (#518): the auto-approved read must not
// reach ~/.ssh, ~/.oxo-flow/ai_config.json, or any other path
// outside the workspace. Without a project directory the tool
// registers unscoped — which is deliberately NOT read-only, so a
// session without a human approver never executes it.
let mut tool_registry = ToolRegistry::new();
match project {
Some(dir) => tool_registry.register(Box::new(builtin::ReadFileTool::scoped(dir))),
None => {
tracing::warn!(
"no project directory — read_file registered unscoped and requires approval"
);
tool_registry.register(Box::new(builtin::ReadFileTool::new()));
}
}
// Knowledge tools come from the single canonical registry
// (oxo-flow-ai); CLI extras (read_file, MCP bridges) sit on top.
tool_registry.merge(builtin::knowledge_tool_registry());
let skill_context = activated_skill_context(project, &config);
// Activated tool skills reference MCP servers via
// `requires = ["mcp://host:port"]` — connect and register their
// tools through the MCP bridge. A failing server warns and is
// skipped; it never blocks the run.
let discovered = oxo_flow_ai::skill::discover_skills(project);
let mut mcp_tools = 0usize;
for skill in discovered {
if skill.skill_type != "tool" || !config.skills.iter().any(|name| name == &skill.name) {
continue;
}
let Some(url) = skill
.requires
.as_ref()
.and_then(|r| r.iter().find(|s| s.starts_with("mcp://")))
else {
tracing::warn!(
"tool skill '{}' has no mcp:// requirement — skipped",
skill.name
);
continue;
};
// McpHttpClient::new screens the endpoint with a blocking
// getaddrinfo hop — run it on the blocking pool so skill
// loading never stalls an async worker on DNS (#268-family
// discipline; the redirect loop already screens asynchronously).
let screen_url = url.clone();
match tokio::task::spawn_blocking(move || {
oxo_flow_ai::mcp::McpHttpClient::new(&screen_url)
})
.await
{
Ok(Ok(client)) => {
match oxo_flow_ai::mcp::McpToolBridge::discover(std::sync::Arc::new(client))
.await
{
Ok(bridges) => {
let count = bridges.len();
for bridge in bridges {
tool_registry.register(Box::new(bridge));
}
mcp_tools += count;
tracing::info!(
"skill '{}': registered {} from {url}",
skill.name,
oxo_flow_core::plural(count, "MCP tool", "MCP tools")
);
}
Err(e) => tracing::warn!(
"skill '{}': failed to discover MCP tools at {url}: {e}",
skill.name
),
}
}
Ok(Err(e)) => {
tracing::warn!("skill '{}': MCP endpoint {url} rejected: {e}", skill.name)
}
Err(e) => tracing::warn!("skill '{}': {e}", skill.name),
}
}
if mcp_tools > 0 {
eprintln!(
" {} {} from activated tool skills (non-read-only calls require approval)",
"•".cyan(),
oxo_flow_core::plural(mcp_tools, "MCP tool", "MCP tools")
);
}
let orchestrator = Orchestrator::new(provider.clone(), config.max_retries);
Ok(Self {
provider,
config,
tool_registry,
orchestrator,
skill_context,
})
}
}
/// Discover user-defined skills (home + project level) and return the
/// assembled prompt context of those explicitly activated in the config
/// (`[ai] skills = [...]`). Discovery is read-only — a skill is never
/// activated unless its name appears in the config. This is the trust
/// boundary: prompt injection only, zero code execution.
pub fn activated_skill_context(project_dir: Option<&Path>, config: &AiConfig) -> String {
let discovered = oxo_flow_ai::skill::discover_skills(project_dir);
let mut registry = oxo_flow_ai::skill::SkillRegistry::new();
for skill in discovered {
if config.skills.iter().any(|name| name == &skill.name) {
tracing::info!("Activated custom skill: {} v{}", skill.name, skill.version);
registry.activate(skill);
}
}
registry.prompt_context().to_string()
}
/// Interactive human approval for a non-read-only tool call, in the sync
/// form the shared
/// [`ToolApprover`](oxo_flow_ai::agent::ToolApprover) closure requires.
/// Prompts on stderr
/// and reads stdin; non-interactive sessions are denied (the safe
/// default — matching the trust boundary: AI never executes autonomously).
pub fn prompt_tool_approval_blocking(tool_name: &str, arguments: &str) -> bool {
use std::io::{IsTerminal, Write as _};
let can_prompt = std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
if !can_prompt {
return false;
}
eprintln!();
eprintln!(" {} tool '{}' is not read-only:", "⚠".yellow(), tool_name);
eprintln!(" Arguments: {arguments}");
eprint!(" Allow execution? [y/N] ");
std::io::stderr().flush().ok();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_ok() {
let trimmed = input.trim();
trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes")
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Drift guard: the CLI runtime must register at least the canonical
/// embedded-knowledge tool set from oxo-flow-ai (single source of
/// truth). CLI extras (scoped read_file, MCP bridges) come on top.
#[test]
fn cli_registry_covers_knowledge_tools() {
let base = oxo_flow_ai::tools::builtin::knowledge_tool_registry();
let names = base.names();
let mut merged = ToolRegistry::new();
merged.merge(base);
for name in names {
assert!(
merged.get(&name).is_some(),
"CLI tool registry lost knowledge tool '{name}' — re-sync with \
oxo_flow_ai::tools::builtin::knowledge_tool_registry"
);
}
}
}