rho-coding-agent 2.30.0

A fast Rust agent harness with a small footprint and opinionated defaults
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! `[openapi.<source>]` configuration and the per-session source plan.
//!
//! Config is the trust boundary, as for MCP: declaring a source is permission
//! to fetch its spec at session start and expose its read operations. Sources
//! come from the user config and from the project file
//! `<project root>/.rho/openapi.toml`. A repository may declare a source, but it
//! cannot attach credentials: `auth` and `headers_from_env` are rejected there,
//! and a user source with the same name replaces the project one entirely, so
//! a repository can never point a user's credentials at its own server.
//!
//! The raw tables round-trip through config saves unchanged. Semantic checks
//! run when a session plans its sources, so one bad source fails alone.

use std::{
    collections::BTreeMap,
    path::{Path, PathBuf},
    time::Duration,
};

use http::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use url::Url;

use super::filter::OperationFilter;
use crate::{
    tools::tool_name::sanitize_segment,
    workspace::{ProjectTrust, TRUST_PROJECT_OPENAPI_ENV},
};

/// Default `timeout_secs`, the same budget an MCP tool call gets.
const DEFAULT_TIMEOUT_SECS: u64 = 120;

/// Project sources live here, relative to the project root.
const PROJECT_FILE: &str = ".rho/openapi.toml";

/// Every `[openapi.<source>]` table, keyed by source name.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(transparent)]
pub(crate) struct OpenApiConfig {
    pub(crate) sources: BTreeMap<String, OpenApiSourceConfig>,
}

impl OpenApiConfig {
    pub(crate) fn is_empty(&self) -> bool {
        self.sources.is_empty()
    }
}

/// One source as written in config.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct OpenApiSourceConfig {
    /// HTTP(S) URL or file path of a JSON or YAML OpenAPI 3.0/3.1 spec.
    pub(crate) spec: String,
    /// Replaces every server the spec declares. Required with credentials.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) base_url: Option<String>,
    /// Literal headers sent to the `base_url` origin.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub(crate) headers: BTreeMap<String, String>,
    /// Header names mapped to environment variable names. User config only.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub(crate) headers_from_env: BTreeMap<String, String>,
    /// One credential for every request to the `base_url` origin. User config only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) auth: Option<AuthConfig>,
    /// Globs on the operationId or `METHOD /path`. Empty allows everything.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) allow: Vec<String>,
    /// Globs on the operationId or `METHOD /path`, applied before `allow`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) deny: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) timeout_secs: Option<u64>,
}

impl OpenApiSourceConfig {
    /// Whether requests carry anything that may be secret. Literal headers
    /// count: `Authorization = "Bearer ..."` is a secret written inline.
    fn has_credentials(&self) -> bool {
        self.auth.is_some() || !self.headers_from_env.is_empty() || !self.headers.is_empty()
    }
}

/// Where a credential comes from. Values are always read from the
/// environment, never written in config.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum AuthConfig {
    /// `Authorization: Bearer <token>`.
    Bearer { token_env: String },
    /// A key sent as one header or query parameter.
    ApiKey {
        value_env: String,
        #[serde(rename = "in")]
        location: ApiKeyLocation,
        name: String,
    },
    /// `Authorization: Basic <base64(username:password)>`.
    Basic {
        username_env: String,
        password_env: String,
    },
}

#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ApiKeyLocation {
    Header,
    Query,
}

/// Which file declared a source.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ConfigOrigin {
    User,
    Project,
}

/// Sources a session should load, read from both config files.
#[derive(Clone, Debug, Default)]
pub(crate) struct OpenApiSources {
    pub(crate) user: OpenApiConfig,
    /// Directory relative user `spec` paths resolve against.
    pub(crate) user_dir: PathBuf,
    /// The project file, when one exists.
    pub(crate) project: Option<ProjectSources>,
}

#[derive(Clone, Debug)]
pub(crate) struct ProjectSources {
    /// An untrusted workspace's file is reported and skipped: its server
    /// gets approval-free GETs, which may reach internal addresses.
    pub(crate) trust: ProjectTrust,
    pub(crate) path: PathBuf,
    /// The project root, which relative `spec` paths resolve against.
    pub(crate) root: PathBuf,
    /// The parsed file, or why it could not be read.
    pub(crate) config: Result<OpenApiConfig, String>,
}

impl OpenApiSources {
    /// User sources from `config`, plus the project file for `cwd` if any.
    pub(crate) fn discover(
        user: &OpenApiConfig,
        config_path: &Path,
        cwd: &Path,
        trust: ProjectTrust,
    ) -> Self {
        let root = crate::plugins::state::project_root(cwd);
        let path = root.join(PROJECT_FILE);
        // An untrusted file is only noticed, never opened: it could be a
        // symlink to `/dev/zero` or a FIFO.
        let project = match trust {
            ProjectTrust::Untrusted => {
                std::fs::symlink_metadata(&path)
                    .is_ok()
                    .then(|| ProjectSources {
                        trust,
                        config: Ok(OpenApiConfig::default()),
                        path,
                        root,
                    })
            }
            ProjectTrust::Trusted => Self::read_project(path, root, trust),
        };
        Self {
            user: user.clone(),
            user_dir: config_path
                .parent()
                .map_or_else(PathBuf::new, Path::to_path_buf),
            project,
        }
    }

    fn read_project(path: PathBuf, root: PathBuf, trust: ProjectTrust) -> Option<ProjectSources> {
        match std::fs::read_to_string(&path) {
            Ok(text) => Some(ProjectSources {
                trust,
                config: parse_project_file(&text),
                path,
                root,
            }),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
            Err(error) => Some(ProjectSources {
                trust,
                config: Err(format!("could not read: {error}")),
                path,
                root,
            }),
        }
    }

    /// Validate every source and resolve its credentials through `env`.
    /// Returns the sources to load and a failure for each one that cannot.
    pub(crate) fn plan(&self, env: &dyn Fn(&str) -> Option<String>) -> SourcePlans {
        let mut plans = SourcePlans::default();
        for (name, source) in &self.user.sources {
            plans.push(
                name,
                plan_source(name, source, &self.user_dir, ConfigOrigin::User, env),
            );
        }
        let Some(project) = &self.project else {
            return plans;
        };
        match project.trust {
            ProjectTrust::Trusted => {}
            ProjectTrust::Untrusted => {
                plans.warnings.push(format!(
                    "ignoring {} because this workspace is not trusted; set {TRUST_PROJECT_OPENAPI_ENV}=1 to load it",
                    crate::paths::display(&project.path)
                ));
                return plans;
            }
        }
        let config = match &project.config {
            Ok(config) => config,
            Err(error) => {
                plans.failures.push(SourceFailure {
                    name: None,
                    error: format!("{}: {error}", project.path.display()),
                });
                return plans;
            }
        };
        let origin = ConfigOrigin::Project;
        for (name, source) in &config.sources {
            if self.user.sources.contains_key(name) {
                plans.warnings.push(format!(
                    "OpenAPI source `{name}` in {} is ignored: user config declares the same source",
                    project.path.display()
                ));
                continue;
            }
            plans.push(
                name,
                plan_source(name, source, &project.root, origin, env)
                    .map_err(|error| format!("{}: {error}", project.path.display())),
            );
        }
        plans
    }
}

fn parse_project_file(text: &str) -> Result<OpenApiConfig, String> {
    #[derive(Deserialize)]
    #[serde(deny_unknown_fields)]
    struct ProjectFile {
        #[serde(default)]
        openapi: OpenApiConfig,
    }
    toml::from_str::<ProjectFile>(text)
        .map(|file| file.openapi)
        .map_err(|error| error.to_string())
}

/// The planned sources and the ones that failed validation.
#[derive(Debug, Default)]
pub(crate) struct SourcePlans {
    pub(crate) sources: Vec<SourcePlan>,
    pub(crate) failures: Vec<SourceFailure>,
    pub(crate) warnings: Vec<String>,
}

impl SourcePlans {
    /// Whether there is nothing to load or report.
    pub(crate) fn is_empty(&self) -> bool {
        self.sources.is_empty() && self.failures.is_empty() && self.warnings.is_empty()
    }

    fn push(&mut self, name: &str, plan: Result<SourcePlan, String>) {
        match plan {
            Ok(plan) => self.sources.push(plan),
            Err(error) => self.failures.push(SourceFailure {
                name: Some(name.to_owned()),
                error,
            }),
        }
    }
}

/// A source that will not load, and why. `name` is `None` when a whole
/// config file could not be read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SourceFailure {
    pub(crate) name: Option<String>,
    pub(crate) error: String,
}

/// A validated source with its credentials resolved.
#[derive(Clone, Debug)]
pub(crate) struct SourcePlan {
    pub(crate) name: String,
    pub(crate) spec: SpecLocation,
    pub(crate) base_url: Option<Url>,
    /// Sent only to the source origin; see [`SourceHeaders`].
    pub(crate) headers: SourceHeaders,
    pub(crate) filter: OperationFilter,
    pub(crate) timeout: Duration,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SpecLocation {
    Url(Url),
    Path(PathBuf),
}

/// Headers and query credentials the source attaches to its own origin only.
/// Configured headers count too: a literal header may still be a secret.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct SourceHeaders {
    pub(crate) headers: HeaderMap,
    /// An `api_key` credential sent `in = "query"`.
    pub(crate) query: Option<(String, String)>,
}

fn plan_source(
    name: &str,
    source: &OpenApiSourceConfig,
    base_dir: &Path,
    origin: ConfigOrigin,
    env: &dyn Fn(&str) -> Option<String>,
) -> Result<SourcePlan, String> {
    if name.is_empty() || sanitize_segment(name) != name {
        return Err(format!(
            "source name `{name}` must be ASCII letters and digits, optionally joined by single underscores"
        ));
    }
    match origin {
        ConfigOrigin::User => {}
        ConfigOrigin::Project => {
            let mut rejected = Vec::new();
            if source.auth.is_some() {
                rejected.push("`auth`");
            }
            if !source.headers_from_env.is_empty() {
                rejected.push("`headers_from_env`");
            }
            if !rejected.is_empty() {
                return Err(format!(
                "source `{name}` sets {} in project config; credentials come only from user config, \
                 so declare `[openapi.{name}]` in ~/.rho/config.toml instead",
                rejected.join(" and ")
            ));
            }
        }
    }
    let base_url = source
        .base_url
        .as_deref()
        .map(|base_url| parse_base_url(name, base_url))
        .transpose()?;
    if source.has_credentials() {
        let Some(base_url) = &base_url else {
            return Err(format!(
                "source `{name}` sends credentials or headers, so it needs a `base_url`: they go only to that origin"
            ));
        };
        crate::tools::mcp::validate::parse_secure_url(
            base_url.as_str(),
            &format!("source `{name}` base_url, which carries credentials,"),
        )
        .map_err(|error| error.to_string())?;
    }
    let timeout = match source.timeout_secs {
        None => Duration::from_secs(DEFAULT_TIMEOUT_SECS),
        Some(0) => return Err(format!("source `{name}` timeout_secs must be at least 1")),
        Some(seconds) => Duration::from_secs(seconds),
    };
    Ok(SourcePlan {
        name: name.to_owned(),
        spec: spec_location(name, &source.spec, base_dir)?,
        base_url,
        headers: resolve_headers(name, source, env)?,
        filter: OperationFilter::new(&source.allow, &source.deny),
        timeout,
    })
}

fn parse_base_url(name: &str, base_url: &str) -> Result<Url, String> {
    let url = Url::parse(base_url)
        .map_err(|error| format!("source `{name}` base_url `{base_url}` is invalid: {error}"))?;
    if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
        return Err(format!(
            "source `{name}` base_url `{base_url}` must be an HTTP(S) URL with a host"
        ));
    }
    if url.query().is_some() || url.fragment().is_some() {
        return Err(format!(
            "source `{name}` base_url `{base_url}` must not have a query or fragment"
        ));
    }
    Ok(url)
}

fn spec_location(name: &str, spec: &str, base_dir: &Path) -> Result<SpecLocation, String> {
    if spec.trim().is_empty() {
        return Err(format!("source `{name}` spec must not be empty"));
    }
    if let Ok(url) = Url::parse(spec) {
        return match url.scheme() {
            "http" | "https" => Ok(SpecLocation::Url(url)),
            "file" => url
                .to_file_path()
                .map(SpecLocation::Path)
                .map_err(|()| format!("source `{name}` spec `{spec}` is not a local file URL")),
            // A Windows path such as `C:\specs\api.yaml` parses as scheme `c`.
            _ if Path::new(spec).is_absolute() => Ok(SpecLocation::Path(PathBuf::from(spec))),
            scheme => Err(format!(
                "source `{name}` spec `{spec}` uses unsupported scheme `{scheme}`"
            )),
        };
    }
    Ok(SpecLocation::Path(base_dir.join(spec)))
}

/// Literal headers first, then environment headers, then `auth`; later
/// entries replace earlier ones with the same name.
fn resolve_headers(
    name: &str,
    source: &OpenApiSourceConfig,
    env: &dyn Fn(&str) -> Option<String>,
) -> Result<SourceHeaders, String> {
    let mut resolved = SourceHeaders::default();
    let mut insert = |header: &str, value: &str, what: &str| -> Result<(), String> {
        let header = HeaderName::try_from(header)
            .map_err(|_| format!("source `{name}` {what} `{header}` is not a valid header name"))?;
        let mut value = HeaderValue::try_from(value)
            .map_err(|_| format!("source `{name}` {what} `{header}` has an invalid value"))?;
        value.set_sensitive(true);
        resolved.headers.insert(header, value);
        Ok(())
    };
    for (header, value) in &source.headers {
        insert(header, value, "header")?;
    }
    let read = |variable: &str, field: &str| {
        env(variable).ok_or_else(|| {
            format!(
                "source `{name}` {field} names environment variable `{variable}`, which is not set"
            )
        })
    };
    for (header, variable) in &source.headers_from_env {
        insert(header, &read(variable, "headers_from_env")?, "header")?;
    }
    match &source.auth {
        None => {}
        Some(AuthConfig::Bearer { token_env }) => {
            let token = read(token_env, "auth.token_env")?;
            insert("authorization", &format!("Bearer {token}"), "auth")?;
        }
        Some(AuthConfig::Basic {
            username_env,
            password_env,
        }) => {
            use base64::Engine as _;
            let username = read(username_env, "auth.username_env")?;
            let password = read(password_env, "auth.password_env")?;
            let encoded =
                base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
            insert("authorization", &format!("Basic {encoded}"), "auth")?;
        }
        Some(AuthConfig::ApiKey {
            value_env,
            location,
            name: key,
        }) => {
            if key.is_empty() {
                return Err(format!("source `{name}` auth.name must not be empty"));
            }
            let value = read(value_env, "auth.value_env")?;
            match location {
                ApiKeyLocation::Header => insert(key, &value, "auth.name")?,
                ApiKeyLocation::Query => resolved.query = Some((key.clone(), value)),
            }
        }
    }
    Ok(resolved)
}

#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;