The swarm settings gain `runtime` (`api-key`, the default, or `acp`) and an `acp` provider record: an https base URL, the provider's model, and its prices per million tokens. A PUT now changes only the fields its body names, so the settings form that sends model, effort and limit leaves the runtime alone; `null` clears `effort` or `acp`. The body stays strict, and the stored document keeps #5192's lenient read. The controller sends the runtime in each run request. On `acp` the classifier runs one prompt on a new `opencode acp` session per classification through hive-runtime's AcpRuntime, in a scratch directory, with a config rendered from the settings. opencode runs `read` and other tools without asking, so OPENCODE_PERMISSION {"*":"deny"} takes every tool away from the model, and a deny-all policy refuses any request on top. The title call, which resends the prompt, is turned off. The deadline cancels the turn, and a turn that does not end within 15s of that is dropped, which also covers an agent stuck before its prompt. The answer is the final assistant text, as ACP has no result event. The provider key is read per run from swarm/services/review-classifier/acp-provider, which the bao UI's admins login may create and update, and reaches opencode through AcpRuntime::with_provider_key and the trampoline's allowlist. Each ACP classification's cost is computed from the configured prices and the reported tokens, and added to hyperhive.review_classifier.cost.usage labelled repo, pr, model and currency. Part of #5096
128 lines
4 KiB
Rust
128 lines
4 KiB
Rust
//! The model child's environment: an allowlist, not the daemon's.
|
|
//!
|
|
//! `hive-claude` and `hive-runtime` set their variables on top of the
|
|
//! parent's environment, and the daemon's holds the store identity paths and
|
|
//! the queue coordinates. So the child is spawned as this same binary with
|
|
//! [`EXEC_CLAUDE`] or [`EXEC_OPENCODE`] naming the real one:
|
|
//! [`exec_if_requested`] runs first in `main`, clears the environment down to
|
|
//! that runtime's allowlist and execs the real binary with the same
|
|
//! arguments, in the same process.
|
|
|
|
use std::ffi::OsString;
|
|
use std::os::unix::process::CommandExt as _;
|
|
use std::process::Command;
|
|
|
|
/// Set on the child only: the `claude` binary to exec.
|
|
pub const EXEC_CLAUDE: &str = "SWARM_REVIEW_CLASSIFIER_EXEC_CLAUDE";
|
|
|
|
/// Set on the child only: the `opencode` binary to exec.
|
|
pub const EXEC_OPENCODE: &str = "SWARM_REVIEW_CLASSIFIER_EXEC_OPENCODE";
|
|
|
|
/// Every variable `claude` keeps: the key, what it needs to run, and its
|
|
/// telemetry settings.
|
|
const CLAUDE_ALLOWED: &[&str] = &[
|
|
"ANTHROPIC_API_KEY",
|
|
"PATH",
|
|
"HOME",
|
|
"CLAUDE_CONFIG_DIR",
|
|
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
"OTEL_METRICS_EXPORTER",
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE",
|
|
"OTEL_RESOURCE_ATTRIBUTES",
|
|
];
|
|
|
|
/// Every variable `opencode` keeps: the provider key, what it needs to run,
|
|
/// and the config [`crate::acp`] renders for it.
|
|
const OPENCODE_ALLOWED: &[&str] = &[
|
|
crate::acp::PROVIDER_KEY_VAR,
|
|
"PATH",
|
|
"HOME",
|
|
"OPENCODE_CONFIG",
|
|
"OPENCODE_PERMISSION",
|
|
"OPENCODE_DISABLE_PROJECT_CONFIG",
|
|
];
|
|
|
|
/// The variables of `vars` on `allowed`.
|
|
fn allowed(
|
|
allowed: &[&str],
|
|
vars: impl IntoIterator<Item = (OsString, OsString)>,
|
|
) -> Vec<(OsString, OsString)> {
|
|
vars.into_iter()
|
|
.filter(|(name, _)| allowed.iter().any(|a| name == a))
|
|
.collect()
|
|
}
|
|
|
|
/// When this process is the trampoline, exec the real binary and never
|
|
/// return.
|
|
pub fn exec_if_requested() {
|
|
for (var, allow) in [
|
|
(EXEC_CLAUDE, CLAUDE_ALLOWED),
|
|
(EXEC_OPENCODE, OPENCODE_ALLOWED),
|
|
] {
|
|
let Some(program) = std::env::var_os(var) else {
|
|
continue;
|
|
};
|
|
let error = Command::new(&program)
|
|
.args(std::env::args_os().skip(1))
|
|
.env_clear()
|
|
.envs(allowed(allow, std::env::vars_os()))
|
|
.exec();
|
|
eprintln!("exec {}: {error}", program.to_string_lossy());
|
|
std::process::exit(127);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The daemon's environment on a run of either runtime.
|
|
fn daemon() -> Vec<(OsString, OsString)> {
|
|
[
|
|
("ANTHROPIC_API_KEY", "k"),
|
|
(crate::acp::PROVIDER_KEY_VAR, "p"),
|
|
("HOME", "/tmp/run"),
|
|
("OTEL_RESOURCE_ATTRIBUTES", "repo=o/r,pr=7"),
|
|
("OPENCODE_PERMISSION", r#"{"*":"deny"}"#),
|
|
(
|
|
"BAO_CLIENT_KEY",
|
|
"/run/credentials/x/review-classifier-bao-key",
|
|
),
|
|
("BAO_ADDR", "https://bao.example:8200"),
|
|
("CREDENTIALS_DIRECTORY", "/run/credentials/x"),
|
|
(
|
|
"SWARM_REVIEW_CLASSIFIER_NATS_URL",
|
|
"tls://nats.example:4222",
|
|
),
|
|
(EXEC_CLAUDE, "/bin/claude"),
|
|
(EXEC_OPENCODE, "/bin/opencode"),
|
|
]
|
|
.map(|(n, v)| (OsString::from(n), OsString::from(v)))
|
|
.into()
|
|
}
|
|
|
|
fn kept(allow: &[&str]) -> Vec<String> {
|
|
allowed(allow, daemon())
|
|
.into_iter()
|
|
.map(|(n, _)| n.to_string_lossy().into_owned())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_allowlist_reaches_claude() {
|
|
assert_eq!(
|
|
kept(CLAUDE_ALLOWED),
|
|
["ANTHROPIC_API_KEY", "HOME", "OTEL_RESOURCE_ATTRIBUTES"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_allowlist_reaches_opencode() {
|
|
assert_eq!(
|
|
kept(OPENCODE_ALLOWED),
|
|
[crate::acp::PROVIDER_KEY_VAR, "HOME", "OPENCODE_PERMISSION"]
|
|
);
|
|
}
|
|
}
|