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
23 changes: 12 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@ categories = ["command-line-utilities", "development-tools"]
[workspace.dependencies]
# Protocol/wire-type crates live in launchapp-dev/animus-protocol, never in
# this repo. `protocol` + `animus-config-protocol` moved out in v0.6.1; all
# animus-protocol git deps are pinned to the SAME tag (v0.1.25) so transitive
# animus-protocol git deps are pinned to the SAME tag (v0.1.26) so transitive
# animus-subject-protocol resolves to one source (no duplicate-crate type
# mismatches). animus-plugin-protocol/runtime remain in-tree pending a
# follow-up move.
animus-plugin-protocol = { path = "crates/animus-plugin-protocol" }
animus-actor = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-config-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-provider-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-session-backend = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-subject-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25", package = "protocol" }
animus-actor = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-config-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-provider-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-session-backend = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-subject-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26", package = "protocol" }
orchestrator-daemon-runtime = { path = "crates/orchestrator-daemon-runtime" }
orchestrator-logging = { path = "crates/orchestrator-logging" }
orchestrator-plugin-host = { path = "crates/orchestrator-plugin-host" }
Expand Down
62 changes: 62 additions & 0 deletions crates/animus-runtime-shared/src/actor_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Back-compat-safe env channel for relaying a run's [`Actor`] from the
//! daemon scheduler to the workflow runner.
//!
//! Owner-scoped schedules (a [`WorkflowSchedule`](animus_config_protocol::WorkflowSchedule)
//! with an `owner_id`) cause the daemon to mint a system [`Actor`] and run the
//! dispatched workflow AS that user. The daemon spawns the workflow runner as a
//! detached subprocess via plain CLI args, so the minted identity is relayed on
//! the [`ANIMUS_ACTOR_JSON_ENV`] environment variable rather than a new CLI
//! flag: an env var is IGNORED by an older runner (no arg-parse error), while a
//! newer runner reads it and threads the actor into its
//! `WorkflowRunInput.actor`, so downstream provider + plugin channels scope to
//! the owner. This keeps owner-scoped schedules additive and back-compat.
//!
//! TRUST BOUNDARY: the actor encoded here MUST originate only from a trusted,
//! config-authored source (the schedule's `owner_id`, asserted at
//! config-authoring time — see `WorkflowSchedule::owner_id`). It is NEVER
//! derived from runtime or agent-generated content.

use animus_actor::Actor;

/// Environment variable carrying the JSON-encoded [`Actor`] a daemon-spawned
/// workflow runner should run as. Absent / empty means a system/global run.
pub const ANIMUS_ACTOR_JSON_ENV: &str = "ANIMUS_ACTOR_JSON";

/// Encode `actor` as the JSON payload for [`ANIMUS_ACTOR_JSON_ENV`]. Returns
/// `None` (omit the env var → global scope) when serialization fails rather
/// than aborting the dispatch.
pub fn encode_actor_env(actor: &Actor) -> Option<String> {
serde_json::to_string(actor).ok()
}

/// Decode the [`ANIMUS_ACTOR_JSON_ENV`] payload back into an [`Actor`]. Returns
/// `None` for an unset / empty / malformed value (the run stays global rather
/// than failing).
pub fn decode_actor_env(raw: Option<&str>) -> Option<Actor> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
serde_json::from_str(raw).ok()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn actor_round_trips_through_env_payload() {
let actor = Actor { user_id: "alice".into(), claims: vec!["admin".into()], tenant_id: Some("team-7".into()) };
let encoded = encode_actor_env(&actor).expect("encode");
let decoded = decode_actor_env(Some(&encoded)).expect("decode");
assert_eq!(actor, decoded);
}

#[test]
fn empty_and_malformed_payloads_decode_to_none() {
assert!(decode_actor_env(None).is_none());
assert!(decode_actor_env(Some("")).is_none());
assert!(decode_actor_env(Some(" ")).is_none());
assert!(decode_actor_env(Some("not json")).is_none());
}
}
1 change: 1 addition & 0 deletions crates/animus-runtime-shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! `phase_targets`, `phase_failover`, `phase_command`, `skill_dispatch`,
//! `direct_exec`) is plugin-private and intentionally NOT here.

pub mod actor_env;
pub mod agent_state;
pub mod config_context;
pub mod ensure_execution_cwd;
Expand Down
16 changes: 8 additions & 8 deletions crates/orchestrator-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orchestrator-cli"
version = "0.6.21"
version = "0.6.22"
edition = "2021"
license = "Elastic-2.0"
default-run = "animus"
Expand Down Expand Up @@ -29,25 +29,25 @@ orchestrator-core = { path = "../orchestrator-core" }
orchestrator-logging = { path = "../orchestrator-logging" }
orchestrator-plugin-host = { workspace = true }
animus-plugin-protocol = { workspace = true }
# v0.1.x-lineage protocol crates. Pinned to v0.1.25 so they share ONE
# v0.1.x-lineage protocol crates. Pinned to v0.1.26 so they share ONE
# `animus-subject-protocol` rev (0.1.16) with the workspace `protocol` dep and
# the control surface — control-protocol's `WorkflowRunSummary.subject_id` etc.
# embed subject-protocol types, so `animus-subject-protocol-wire` MUST match
# control's rev. v0.1.25 additively adds `actor: Option<Actor>` to the control +
# control's rev. v0.1.26 additively adds `actor: Option<Actor>` to the control +
# workflow-runner request types relayed by the control surface / plugin_clients.
animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-log-storage-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-subject-protocol-wire = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-log-storage-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-subject-protocol-wire = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-actor = { workspace = true }
# Plugin protocol crates used by the plugin-host wrappers in
# `services::plugin_clients` to dispatch `workflow/*` and `queue/*` RPCs through
# installed `workflow_runner` / `queue` plugins. The runner gains `actor` at
# v0.1.25. `animus-queue-protocol` + `animus-subject-protocol-v05` stay on the
# v0.1.26. `animus-queue-protocol` + `animus-subject-protocol-v05` stay on the
# matched v0.5.10 pair so `SubjectId` unifies in `QueueLeaseRequest`; they must
# NOT share `animus-subject-protocol-wire`'s rev (cargo forbids two names for one
# package instance), so they stay a distinct rev.
animus-queue-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.5.10" }
animus-workflow-runner-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" }
animus-workflow-runner-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26" }
animus-subject-protocol-v05 = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.5.10" }
protocol = { workspace = true }
animus-session-backend = { workspace = true }
Expand Down
86 changes: 85 additions & 1 deletion crates/orchestrator-cli/src/services/runtime/agent_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
//! machinery, so no resolved secret rides the contract.

use std::collections::BTreeSet;
use std::path::Path;
use std::path::{Path, PathBuf};

use animus_actor::Actor;
use anyhow::{anyhow, Context, Result};
Expand Down Expand Up @@ -565,6 +565,42 @@ pub(crate) fn materialize_mcp_json(cwd: &Path, runtime_contract: &Value) -> Resu
Ok(written)
}

/// Materialize the FULL (un-stripped) `runtime_contract` MCP server set into a
/// per-run ISOLATED directory and return the path of the written `.mcp.json`.
///
/// This is the actor-scoped counterpart to [`materialize_mcp_json`]: where the
/// shared cwd file is written actor-STRIPPED (a concurrent run, or a crash
/// before cleanup, must never let another caller inherit the per-turn identity
/// from the persisted, auto-discovered cwd file), this file lives in a fresh,
/// run-private directory and keeps the actor-pinned `animus mcp serve
/// --actor-json <json>` command intact. A provider that locates MCP servers by
/// file auto-discovery can be pointed at THIS path (e.g. claude-code's
/// `--mcp-config`) so the actor reaches that channel too — without ever
/// touching the shared cwd `.mcp.json`.
///
/// The directory is assumed to be run-private and freshly created, so there is
/// no user-file merge and no managed-marker bookkeeping: the resolved set is
/// written wholesale. Returns the `.mcp.json` path, or `None` when the contract
/// resolves to no servers (nothing to point a provider at).
pub(crate) fn materialize_isolated_mcp_json(dir: &Path, runtime_contract: &Value) -> Result<Option<PathBuf>> {
let resolved = contract_mcp_servers_for_mcp_json(runtime_contract);
if resolved.is_empty() {
return Ok(None);
}

let mut written: Vec<String> = resolved.keys().cloned().collect();
written.sort();
let mut root = serde_json::Map::new();
root.insert("mcpServers".to_string(), Value::Object(resolved));
root.insert(ANIMUS_MANAGED_MARKER.to_string(), Value::Array(written.into_iter().map(Value::String).collect()));

let mcp_path = dir.join(".mcp.json");
let serialized = format!("{}\n", serde_json::to_string_pretty(&Value::Object(root))?);
std::fs::write(&mcp_path, serialized)
.with_context(|| format!("failed to write isolated MCP config at {}", mcp_path.display()))?;
Ok(Some(mcp_path))
}

/// Return a clone of `runtime_contract` with any `--actor-json <json>` pair
/// stripped from the built-in `animus` server's `/mcp/stdio/args`.
///
Expand Down Expand Up @@ -1260,6 +1296,54 @@ mod tests {
assert!(args.contains(&"mcp") && args.contains(&"serve"), "args: {args:?}");
}

#[test]
fn materialize_isolated_mcp_json_keeps_actor_and_skips_shared_cwd() {
// The isolated file lives in a run-private dir and retains the
// actor-pinned `--actor-json` command, while the shared cwd file (if
// any) is unaffected by this call.
let isolated = tempfile::tempdir().unwrap();
let contract = serde_json::json!({
"mcp": {
"agent_id": "animus",
"stdio": {
"command": "animus",
"args": [
"--project-root", "/p", "mcp", "serve",
"--actor-json", r#"{"user_id":"alice","claims":["admin"]}"#
]
}
}
});

let path = materialize_isolated_mcp_json(isolated.path(), &contract).unwrap().expect("servers resolved");
assert_eq!(path, isolated.path().join(".mcp.json"), "isolated config lands in the run-private dir");

let on_disk: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let args: Vec<&str> = on_disk
.pointer("/mcpServers/animus/args")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
assert!(args.contains(&"--actor-json"), "isolated config must retain the actor flag: {args:?}");
assert!(args.iter().any(|a| a.contains("alice")), "isolated config must retain the actor identity: {args:?}");
// The marker is recorded so a consumer knows the entry is Animus-managed.
assert!(
on_disk.pointer(&format!("/{ANIMUS_MANAGED_MARKER}")).is_some(),
"managed marker must be recorded: {on_disk}"
);
}

#[test]
fn materialize_isolated_mcp_json_returns_none_without_servers() {
let isolated = tempfile::tempdir().unwrap();
let contract = serde_json::json!({ "mcp": {} });
assert!(
materialize_isolated_mcp_json(isolated.path(), &contract).unwrap().is_none(),
"no servers => nothing to point a provider at, and no file is written"
);
assert!(!isolated.path().join(".mcp.json").exists(), "no .mcp.json should be created when empty");
}

#[test]
fn materialize_mcp_json_preserves_user_authored_entries() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading