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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

- **`provider::api_key_variables()`**: every environment variable yoagent reads a credential from — each provider's key variables as `resolve_api_key` reads them (Bedrock's `AWS_*` included), the `YOAGENT_API_KEY` / `API_KEY` fallback, and the decision presets' keys (`TYPESAFE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_AUTH_TOKEN`) — for hosts that blank credentials in processes they start (plugin runtimes, MCP servers). `resolve_api_key` now reads the same per-provider table, so a provider's variables cannot drift from the list (Bedrock's and the decision presets' names are pinned by tests). Additive. Asked for by yobuild.

## yoagent-rutis Unreleased

yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.8.0. Additive.

- **`probe::pi_with` / `probe::dsh_with` and `ProbeOptions`**: the probe's Node runtime can be given environment variables (`ProbeOptions::default().env(name, value)`, `OsString`s, an empty value included), set over the inherited environment; the DSH probe's private `DSH_HOME` / `DSH_AGENTS_HOME` are set after them and win. `probe::pi` / `dsh` are unchanged. For hosts that blank provider keys in every plugin runtime (yobuild's `check`, whose probes were the one runtime still inheriting them). Asked for by yobuild.

## yoagent-rutis 0.8.0 (2026-10-20)

yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.1. A minor bump: new values and settings for the pi adapter's grants.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ The generic hooks it rides on need no feature: `ToolCallRequest::messages` / `ru

### Construction API

The primary constructor is `Agent::from_config(ModelConfig)`: it selects the built-in provider from `config.api`, sets the model id from `config.id`, and resolves the API key from the provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, …; see `provider::resolve_api_key`). This removes the provider↔config pairing footgun and the doubly-specified model id.
The primary constructor is `Agent::from_config(ModelConfig)`: it selects the built-in provider from `config.api`, sets the model id from `config.id`, and resolves the API key from the provider-conventional env var (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, …; see `provider::resolve_api_key`, which reads the private `PROVIDER_KEY_VARIABLES` table in `registry.rs`; `provider::api_key_variables()` lists that table, Bedrock's `AWS_*`, the `YOAGENT_API_KEY`/`API_KEY` fallback and the decision presets' keys — a test pins the decision ones to `decision::systemone`'s constants). This removes the provider↔config pairing footgun and the doubly-specified model id.

```rust
// provider auto-selected, key from XAI_API_KEY
Expand Down
4 changes: 4 additions & 0 deletions docs/providers/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ resolves the key from the environment variable named for `ModelConfig.provider`
| `local`, `ollama` | none needed (empty key) |
| anything else | `YOAGENT_API_KEY`, `API_KEY` |

`provider::api_key_variables()` lists every one of these (and the decision
presets' keys) once: a host that keeps credentials from processes it starts —
plugin runtimes, MCP servers — can blank each of them there.

**wasm32 (Cloudflare Workers):** there are no environment variables, so none of
the above is found. Pass every key with `with_api_key` (see
[WebAssembly & Cloudflare Workers](../guides/wasm-workers.md)).
Expand Down
2 changes: 1 addition & 1 deletion integrations/yoagent-rutis/CLAUDE.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion integrations/yoagent-rutis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,11 @@ what the plugin logged — without calling a tool or a model (loading runs
the package's code, as its own host would). Each report's `problem()` is
the verdict: `None` for a clean load, else what kept the package from
giving an agent what it offers (a refusal after load included). Probes in
one process run one at a time.
one process run one at a time. The probe's Node process inherits this
process's environment; `probe::pi_with` / `dsh_with` take a `ProbeOptions`
with variables to set on top (`ProbeOptions::default().env(name, value)`;
an empty value blanks one) — a host that keeps provider keys from plugins
sets each of `yoagent::provider::api_key_variables()` to `""` there too.

```rust,no_run
# async fn check() {
Expand Down
95 changes: 80 additions & 15 deletions integrations/yoagent-rutis/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@
//! **A verdict.** Each report's `problem()` says what kept the package from
//! giving an agent what it offers, if anything; `None` is a clean load.
//!
//! **The runtime's environment.** The probe's Node process inherits this
//! process's environment. [`pi_with`] / [`dsh_with`] take a [`ProbeOptions`]
//! with variables to set on top — a host that keeps credentials from plugins
//! sets each of `yoagent::provider::api_key_variables()` to the empty
//! string, as it does for the runtimes it starts itself.
//!
//! **One at a time.** Probes in one process run one after another (a second
//! waits): [`Logs`] is one buffer for the process, and each report carries
//! the lines of its own load. Loading a package runs its code, as its own
//! host would.

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
Expand Down Expand Up @@ -240,13 +247,32 @@ struct Host {
loader: Loader,
}

/// How a probe starts its runtime. `Default` is what [`pi`] and [`dsh`] use.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ProbeOptions {
/// Variables set in the probe's Node process, over the environment it
/// inherits; an empty value sets the variable to the empty string (a
/// launcher cannot remove one). The probe's own (the DSH probe's private
/// `DSH_HOME` / `DSH_AGENTS_HOME`) are set after these and win.
pub env: Vec<(OsString, OsString)>,
}

impl ProbeOptions {
/// These options with `name` set to `value` in the probe's runtime.
pub fn env(mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env.push((name.into(), value.into()));
self
}
}

/// A rutis root with the bridge and one Node runtime (`runtime`, the
/// `@arcships/rutis-runtime` package) whose `package.json` is
/// `package_json`, where row names resolve; its process given `env`.
async fn host(
runtime: &Path,
package_json: &Path,
env: &[(&str, &Path)],
env: &[(OsString, OsString)],
) -> Result<Host, BoxError> {
let root = Ctx::root()?;
let bridge = RutisBridge::install(&root)?;
Expand All @@ -260,7 +286,7 @@ async fn host(
let launcher = env
.iter()
.fold(Launcher::node(runtime), |launcher, (name, value)| {
launcher.env(*name, *value)
launcher.env(name.clone(), value.clone())
});
let node = LocalRuntime::launcher("node", launcher, package_json);
let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog));
Expand Down Expand Up @@ -464,6 +490,26 @@ pub async fn pi(
extensions: &[String],
skill_paths: &[PathBuf],
logs: Option<&Logs>,
) -> PiReport {
pi_with(
plugins,
cwd,
extensions,
skill_paths,
logs,
&ProbeOptions::default(),
)
.await
}

/// [`pi`], with the runtime started as `options` say (its environment).
pub async fn pi_with(
plugins: &Path,
cwd: &Path,
extensions: &[String],
skill_paths: &[PathBuf],
logs: Option<&Logs>,
options: &ProbeOptions,
) -> PiReport {
let _one = ONE_AT_A_TIME.lock().await;
let mut report = PiReport {
Expand All @@ -473,7 +519,15 @@ pub async fn pi(
if extensions.is_empty() {
return report;
}
pi_load(&mut report, &plugins.join("pi"), cwd, extensions, logs).await;
pi_load(
&mut report,
&plugins.join("pi"),
cwd,
extensions,
logs,
options,
)
.await;
report.logs = logs.map(Logs::lines).unwrap_or_default();
report
}
Expand All @@ -484,6 +538,7 @@ async fn pi_load(
cwd: &Path,
extensions: &[String],
logs: Option<&Logs>,
options: &ProbeOptions,
) {
let runtime = pi_dir.join("node_modules/@arcships/rutis-runtime");
let mut without: Vec<String> = Vec::new();
Expand All @@ -494,7 +549,7 @@ async fn pi_load(
logs.clear();
}
report.without = without.clone();
let host = match host(&runtime, &pi_dir.join("package.json"), &[]).await {
let host = match host(&runtime, &pi_dir.join("package.json"), &options.env).await {
Ok(host) => host,
Err(e) => {
report.loaded = Some(false);
Expand Down Expand Up @@ -623,12 +678,24 @@ pub async fn dsh(
rows: &Value,
inject: &[String],
logs: Option<&Logs>,
) -> DshReport {
dsh_with(plugins, base, rows, inject, logs, &ProbeOptions::default()).await
}

/// [`dsh`], with the runtime started as `options` say (its environment).
pub async fn dsh_with(
plugins: &Path,
base: &Path,
rows: &Value,
inject: &[String],
logs: Option<&Logs>,
options: &ProbeOptions,
) -> DshReport {
let _one = ONE_AT_A_TIME.lock().await;
if let Some(logs) = logs {
logs.clear();
}
let mut report = match dsh_load(&plugins.join("dsh"), base, rows, inject).await {
let mut report = match dsh_load(&plugins.join("dsh"), base, rows, inject, options).await {
Ok(report) => report,
Err(e) => DshReport {
error: Some(e.to_string()),
Expand All @@ -644,23 +711,21 @@ async fn dsh_load(
base: &Path,
rows: &Value,
inject: &[String],
options: &ProbeOptions,
) -> Result<DshReport, BoxError> {
let plugin_ids = row_ids(rows)?;
let mut report = DshReport::default();
let runtime = base.join("node_modules/@arcships/rutis-runtime");
let dsh_home = tempfile::tempdir()?;
let agents_home = dsh_home.path().join("agents");
let base_rows = dsh_base_rows(dsh_dir, base)?;
let host = host(
&runtime,
&base.join("package.json"),
&[
("DSH_HOME", dsh_home.path()),
("DSH_AGENTS_HOME", &agents_home),
],
)
.await
.map_err(|e| format!("host: {e}"))?;
// The caller's variables, then the probe's own private homes (which win).
let mut env = options.env.clone();
env.push(("DSH_HOME".into(), dsh_home.path().into()));
env.push(("DSH_AGENTS_HOME".into(), agents_home.clone().into()));
let host = host(&runtime, &base.join("package.json"), &env)
.await
.map_err(|e| format!("host: {e}"))?;
// The base alone first, then the plugin's rows on top: the difference is
// what the plugin adds.
let base_state = load(&host, vec![("base", base_rows.clone())]).await;
Expand Down
82 changes: 82 additions & 0 deletions integrations/yoagent-rutis/tests/probe_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,85 @@ async fn a_base_of_its_own_gets_the_probe_files_copied_in() {
assert!(base.path().join("probe-services.ts").is_file());
assert!(base.path().join("dsh-tools-adapter.ts").is_file());
}

/// `pi_with` / `dsh_with`: the caller's variables reach the probe's runtime,
/// an empty value included (how a host blanks provider keys); the DSH
/// probe's own private `DSH_HOME` wins over a caller's.
#[tokio::test(flavor = "multi_thread")]
async fn probe_options_set_the_runtime_s_environment() {
let pi = adapters().pi;
if !installed(&pi) {
return;
}
let dir = tempfile::tempdir().unwrap();
let extension = dir.path().join("env.ts");
std::fs::write(
&extension,
"const said = (v) => v === undefined ? 'unset' : v === '' ? 'empty' : v\n\
export default function (pi) { pi.registerTool({ \
name: `seen_${said(process.env.PROBE_VALUE)}_${said(process.env.PROBE_BLANK)}`, \
label: 'x', description: 'x', parameters: { type: 'object', properties: {} }, \
async execute() { return { content: [], details: {} } } }) }",
)
.unwrap();
let options = probe::ProbeOptions::default()
.env("PROBE_VALUE", "abc")
.env("PROBE_BLANK", "");
let report = probe::pi_with(
&adapters().root,
dir.path(),
&[extension.display().to_string()],
&[],
None,
&options,
)
.await;
assert!(
report.tools.iter().any(|t| t == "seen_abc_empty"),
"{report:?}"
);
// Without options: unset, as before.
let report = probe::pi(
&adapters().root,
dir.path(),
&[extension.display().to_string()],
&[],
None,
)
.await;
assert!(
report.tools.iter().any(|t| t == "seen_unset_unset"),
"{report:?}"
);

let dsh = adapters().dsh;
if !installed(&dsh) {
return;
}
let out = dir.path().join("dsh-env.json");
let plugin = dir.path().join("env-plugin.ts");
std::fs::write(
&plugin,
format!(
"import {{ writeFileSync }} from 'node:fs'\n\
export const name = 'env-probe'\n\
export function apply() {{ writeFileSync({out:?}, JSON.stringify({{ \
value: process.env.PROBE_VALUE, home: process.env.DSH_HOME }})) }}"
),
)
.unwrap();
let rows = json!([{ "id": "env", "name": plugin }]);
let options = probe::ProbeOptions::default()
.env("PROBE_VALUE", "abc")
.env("DSH_HOME", "/caller/home");
let report = probe::dsh_with(&adapters().root, &dsh, &rows, &[], None, &options).await;
assert_eq!(report.error, None, "{report:?}");
let seen: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
assert_eq!(seen["value"], "abc", "{seen}");
// Set, and the probe's own (a caller's does not override it).
assert!(
seen["home"].as_str().is_some_and(|h| h != "/caller/home"),
"{seen}"
);
}
5 changes: 5 additions & 0 deletions src/decision/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ mod mock;
mod openai;
mod question;
mod systemone;
/// For `provider::api_key_variables`' test: the decision presets' key variables.
#[cfg(test)]
pub(crate) use systemone::{
CLOUDFLARE_API_TOKEN_ENV, CLOUDFLARE_AUTH_TOKEN_ENV, TYPESAFE_API_KEY_ENV,
};

pub use advisory::Advisory;
pub use answer::{
Expand Down
2 changes: 1 addition & 1 deletion src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub use prices::{
PRICED_PROVIDERS, PRICES_ENV_VAR, PRICE_SCHEMA_VERSION,
};
pub(crate) use registry::resolve_api_key_or_warn;
pub use registry::{resolve_api_key, ProviderRegistry};
pub use registry::{api_key_variables, resolve_api_key, ProviderRegistry};
pub use tool_args::{parse_tool_arguments, unparsed_tool_arguments, UNPARSED_ARGUMENTS_KEY};
pub use traits::*;
pub use turn_hook::TurnHookProvider;
Expand Down
Loading
Loading