From 9eb9aec564479ab60637f807e7d1ec7ac866aeae Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 15:13:59 -0600 Subject: [PATCH 1/3] feat(chat): per-run isolated actor-pinned MCP config (codex-p1 tail) Providers that locate MCP servers only by cwd `.mcp.json` auto-discovery (ignoring the runtime contract's `mcp` block) previously ran the auto-discovered `animus` server UNSCOPED on actor-asserted runs: the actor is deliberately stripped from the shared, persisted cwd file so a concurrent run or a crash can't let another caller inherit the per-turn identity. Close the gap without touching the shared file: for actor-scoped chat runs, also materialize the FULL (actor-pinned) contract into a per-run ISOLATED temp dir and surface its `.mcp.json` path on `extras.mcp_config_path`. A provider that auto-discoves by file can be pointed at this run-private file (e.g. claude-code's `--mcp-config`) so the actor reaches that channel too. Consuming `extras.mcp_config_path` (passing `--mcp-config ` to the provider CLI) is provider-launch plumbing that lives in the provider plugin; until a plugin adopts it, contract-consuming providers stay scoped via `extras.runtime_contract`. The shared cwd `.mcp.json` remains actor-stripped. Resolves the TODO(codex-p1). --- .../src/services/runtime/agent_mcp.rs | 86 ++++++++++++++++++- .../src/services/runtime/runtime_chat/mod.rs | 37 ++++++-- .../src/services/runtime/runtime_chat/turn.rs | 25 +++++- 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs b/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs index 4908e553..897ae8b2 100644 --- a/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs +++ b/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs @@ -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}; @@ -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 ` 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> { + let resolved = contract_mcp_servers_for_mcp_json(runtime_contract); + if resolved.is_empty() { + return Ok(None); + } + + let mut written: Vec = 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 ` pair /// stripped from the built-in `animus` server's `/mcp/stdio/args`. /// @@ -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(); diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_chat/mod.rs b/crates/orchestrator-cli/src/services/runtime/runtime_chat/mod.rs index 0ac316fa..26b8d125 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_chat/mod.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_chat/mod.rs @@ -428,13 +428,25 @@ async fn handle_chat_send(args: ChatSendArgs, project_root: &str, json: bool) -> // rides the ephemeral `extras.runtime_contract` (below) for the turn's own // provider session, so contract-consuming providers are still scoped. // - // TODO(codex-p1): providers that scope ONLY off the cwd `.mcp.json` (and - // ignore the runtime contract's `mcp` block) therefore run the auto- - // discovered `animus` server unscoped. Closing that gap needs a per-run - // isolated MCP config path the provider is pointed at (e.g. claude's - // `--mcp-config`), which is provider-launch plumbing tracked separately; - // persisting the identity into the shared file is not an acceptable - // stopgap. + // Providers that scope ONLY off the cwd `.mcp.json` (and ignore the + // runtime contract's `mcp` block) would otherwise run the auto-discovered + // `animus` server unscoped. We close that gap WITHOUT persisting the + // identity into the shared file: when an actor is asserted we ALSO + // materialize the FULL (actor-pinned) contract into a per-run ISOLATED + // directory and surface its `.mcp.json` path on `extras.mcp_config_path` + // (see [`run_turn`]). A provider that locates servers by file + // auto-discovery can be pointed at this run-private file (e.g. + // claude-code's `--mcp-config`) so the actor reaches that channel too. The + // isolated dir is held alive (`_isolated_mcp_dir`) for the whole turn and + // cleaned on drop. + // + // Consuming `extras.mcp_config_path` (passing `--mcp-config ` to the + // provider CLI) is provider-launch plumbing that lives in the provider + // plugin; until a plugin honors it, contract-consuming providers remain + // scoped via `extras.runtime_contract` and the gap is closed only for + // plugins that adopt the path. This is the documented out-of-tree tail. + let mut isolated_mcp_config_path: Option = None; + let mut _isolated_mcp_dir: Option = None; if let Some(contract) = mcp_contract.as_ref() { let for_disk = if actor.is_some() { std::borrow::Cow::Owned(crate::services::runtime::agent_mcp::strip_actor_from_contract(contract)) @@ -442,6 +454,16 @@ async fn handle_chat_send(args: ChatSendArgs, project_root: &str, json: bool) -> std::borrow::Cow::Borrowed(contract) }; crate::services::runtime::agent_mcp::materialize_mcp_json(&cwd, &for_disk)?; + + if actor.is_some() { + let dir = tempfile::Builder::new() + .prefix("animus-mcp-actor-") + .tempdir() + .context("failed to create isolated MCP config dir for actor-scoped run")?; + isolated_mcp_config_path = + crate::services::runtime::agent_mcp::materialize_isolated_mcp_json(dir.path(), contract)?; + _isolated_mcp_dir = Some(dir); + } } // Sink selection: --json => JSONL stdout; --stream (no json) => text; @@ -465,6 +487,7 @@ async fn handle_chat_send(args: ChatSendArgs, project_root: &str, json: bool) -> permission_mode: permission_mode.as_deref(), approvals, mcp_contract: mcp_contract.as_ref(), + isolated_mcp_config_path: isolated_mcp_config_path.as_deref(), skill: skill_application, }; diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs b/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs index caa641a0..95f7c115 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs @@ -34,7 +34,7 @@ //! alternate `TurnProducer` without changing the continuity logic here, and //! tests inject a scripted mock producer. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use animus_session_backend::session::{SessionEvent, SessionRequest, SessionRun}; @@ -178,6 +178,13 @@ pub(crate) struct TurnContext<'a> { /// `extras.runtime_contract` so the provider wires the profile/skill- /// scoped MCP servers. `None` when the tool cannot speak MCP. pub mcp_contract: Option<&'a Value>, + /// Path to a per-run ISOLATED, actor-pinned `.mcp.json` for actor-scoped + /// runs. Threaded into `extras.mcp_config_path` so a provider that locates + /// MCP servers by file auto-discovery can be pointed at this run-private + /// file (e.g. claude-code's `--mcp-config`) instead of the actor-stripped + /// shared cwd file. `None` for global / non-actor runs. Consuming this path + /// is provider-launch plumbing tracked out-of-tree. + pub isolated_mcp_config_path: Option<&'a Path>, /// The `--skill`'s full application for this conversation, resolved ONCE /// per `animus chat send` invocation (the same lifecycle as /// `mcp_contract`) and applied to every attempt within the turn: prompt @@ -467,6 +474,20 @@ async fn drive_once( } } + // Actor-scoped runs also expose a per-run ISOLATED, actor-pinned + // `.mcp.json` path. A provider that auto-discovers MCP servers from a file + // (rather than the runtime contract) can be pointed at this run-private + // file via its MCP-config flag (e.g. claude-code's `--mcp-config`) so the + // actor reaches that channel too — without the identity ever landing in + // the shared cwd `.mcp.json`. Consuming this is provider-launch plumbing + // (out-of-tree); contract-consuming providers stay scoped via + // `extras.runtime_contract` above. + if let Some(path) = ctx.isolated_mcp_config_path { + if let Value::Object(map) = &mut extras { + map.insert("mcp_config_path".to_string(), Value::String(path.to_string_lossy().into_owned())); + } + } + // Skill env rides `SessionRequest.env_vars`; the plugin host still gates // the forwarded env against the provider plugin's manifest. let env_vars: Vec<(String, String)> = ctx @@ -834,6 +855,7 @@ mod tests { permission_mode: None, approvals: false, mcp_contract: None, + isolated_mcp_config_path: None, skill: None, } } @@ -1264,6 +1286,7 @@ mod tests { permission_mode: None, approvals: false, mcp_contract: None, + isolated_mcp_config_path: None, skill: None, }, )) From 46ca75953d7d5f327efbeefc9c292dbe7644af54 Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 15:14:10 -0600 Subject: [PATCH 2/3] feat(schedule): owner-scoped schedules mint the owner's actor (WU-F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A config-declared owner on a schedule now causes the daemon to run the dispatched workflow AS that user, so it resolves the owner's config partition and integrations instead of running globally. - Repin animus-protocol git deps to v0.1.26 (additive WorkflowSchedule owner_id + claims; SubjectDispatch.actor + with_actor builder). - schedule_dispatch mints a system Actor from owner_id/claims and attaches it to the SubjectDispatch; a schedule with no owner_id stays global (actor = None). This is the ONE place the kernel CONSTRUCTS an actor rather than relaying one — sound because owner_id is asserted at config-authoring time (trusted config_source), never from runtime/agent content. Documented at the mint site. - build_runner_command relays the minted actor to the workflow runner over the back-compat-safe ANIMUS_ACTOR_JSON env channel (new animus-runtime-shared::actor_env): an older runner ignores it, a newer one threads it into WorkflowRunInput.actor. Global dispatch clears the env so the runner never inherits the daemon's identity. NOTE: Cargo.lock is intentionally left at v0.1.25 — the v0.1.26 protocol tag must be pushed first (see report); cargo regenerates the lock on the first build after the tag lands. --- Cargo.toml | 14 +- crates/animus-runtime-shared/src/actor_env.rs | 62 +++++++++ crates/animus-runtime-shared/src/lib.rs | 1 + crates/orchestrator-cli/Cargo.toml | 14 +- .../src/workflow_config/tests.rs | 6 + crates/orchestrator-daemon-runtime/Cargo.toml | 16 +-- .../build_runner_command_from_dispatch.rs | 51 ++++++++ .../src/schedule/schedule_dispatch.rs | 120 +++++++++++++++++- .../tests/tick_budget_joint.rs | 2 + 9 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 crates/animus-runtime-shared/src/actor_env.rs diff --git a/Cargo.toml b/Cargo.toml index 275eee88..52d4b9c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/animus-runtime-shared/src/actor_env.rs b/crates/animus-runtime-shared/src/actor_env.rs new file mode 100644 index 00000000..bbaff24e --- /dev/null +++ b/crates/animus-runtime-shared/src/actor_env.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/crates/animus-runtime-shared/src/lib.rs b/crates/animus-runtime-shared/src/lib.rs index d03d2794..1e60d8fe 100644 --- a/crates/animus-runtime-shared/src/lib.rs +++ b/crates/animus-runtime-shared/src/lib.rs @@ -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; diff --git a/crates/orchestrator-cli/Cargo.toml b/crates/orchestrator-cli/Cargo.toml index fabdd0c4..28c9638d 100644 --- a/crates/orchestrator-cli/Cargo.toml +++ b/crates/orchestrator-cli/Cargo.toml @@ -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` to the control + +# control's rev. v0.1.26 additively adds `actor: Option` 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 } diff --git a/crates/orchestrator-config/src/workflow_config/tests.rs b/crates/orchestrator-config/src/workflow_config/tests.rs index bb859cb9..0132668f 100644 --- a/crates/orchestrator-config/src/workflow_config/tests.rs +++ b/crates/orchestrator-config/src/workflow_config/tests.rs @@ -1880,6 +1880,8 @@ fn validate_rejects_invalid_unified_sections() { command: None, enabled: true, input: None, + owner_id: None, + claims: Vec::new(), }); config.tools.insert( "cli-gpt".to_string(), @@ -1954,6 +1956,8 @@ fn validate_rejects_schedule_with_command() { command: Some("echo conflict".to_string()), input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }); let err = validate_workflow_config(&config).expect_err("schedules defining both workflow and command should be rejected"); @@ -1975,6 +1979,8 @@ fn validate_rejects_invalid_cron_expression() { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }); let err = validate_workflow_config(&config).expect_err("schedules with malformed cron should fail validation"); let message = err.to_string(); diff --git a/crates/orchestrator-daemon-runtime/Cargo.toml b/crates/orchestrator-daemon-runtime/Cargo.toml index 212b3d92..de7018b2 100644 --- a/crates/orchestrator-daemon-runtime/Cargo.toml +++ b/crates/orchestrator-daemon-runtime/Cargo.toml @@ -24,15 +24,15 @@ orchestrator-core = { workspace = true } orchestrator-logging = { workspace = true } orchestrator-plugin-host = { workspace = true } animus-plugin-protocol = { workspace = true } -# All animus-protocol git deps pin the SAME tag (v0.1.25) so the transitively -# pulled `animus-subject-protocol` resolves to ONE source rev. v0.1.25 additively +# All animus-protocol git deps pin the SAME tag (v0.1.26) so the transitively +# pulled `animus-subject-protocol` resolves to ONE source rev. v0.1.26 additively # adds `actor: Option` to the control request types relayed by the daemon. -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-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-actor = { workspace = true } -# Subject / SubjectChangedEvent share the same v0.1.25 surface as the +# Subject / SubjectChangedEvent share the same v0.1.26 surface as the # control-protocol (one source rev so the protocol versions stay in lockstep). -animus-subject-protocol-wire = { package = "animus-subject-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.26" } protocol = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -51,14 +51,14 @@ interprocess = "2.4" protocol = { workspace = true, features = ["test-utils"] } tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "signal", "test-util", "time"] } -animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25", features = ["client"] } +animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.26", features = ["client"] } async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } futures-core = "0.3" futures-util = "0.3" serde_json = "1.0" uuid = { version = "1", features = ["v4"] } -animus-subject-protocol-wire = { package = "animus-subject-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.26" } animus-runtime-shared = { workspace = true } [lints] diff --git a/crates/orchestrator-daemon-runtime/src/dispatch/build_runner_command_from_dispatch.rs b/crates/orchestrator-daemon-runtime/src/dispatch/build_runner_command_from_dispatch.rs index 57ce12b2..c03d8df2 100644 --- a/crates/orchestrator-daemon-runtime/src/dispatch/build_runner_command_from_dispatch.rs +++ b/crates/orchestrator-daemon-runtime/src/dispatch/build_runner_command_from_dispatch.rs @@ -45,6 +45,20 @@ pub fn build_runner_command( cmd.arg("--mcp-config-json").arg(json); } } + + // Owner-scoped dispatch: relay the kernel-minted actor to the workflow + // runner over the back-compat-safe env channel (an older runner ignores + // the env var; a newer one threads it into `WorkflowRunInput.actor` so the + // run scopes to the owner). We always clear any inherited value first so a + // global dispatch never leaks the daemon's own env into the runner. + cmd.env_remove(animus_runtime_shared::actor_env::ANIMUS_ACTOR_JSON_ENV); + if let Some(actor) = dispatch.actor.as_ref() { + if let Some(json) = animus_runtime_shared::actor_env::encode_actor_env(actor) { + cmd.env(animus_runtime_shared::actor_env::ANIMUS_ACTOR_JSON_ENV, json); + } else { + warn!("failed to encode schedule owner actor; dispatching workflow runner with global scope"); + } + } cmd } @@ -431,6 +445,43 @@ mod tests { ); } + #[test] + fn runner_command_relays_actor_env_for_owner_scoped_dispatch() { + let dispatch = SubjectDispatch::for_custom("schedule:nightly", "nightly", "schedule", None, "schedule") + .with_actor(Some(animus_actor::Actor { + user_id: "alice".to_string(), + claims: vec!["admin".to_string()], + tenant_id: None, + })); + let command = build_runner_command_from_dispatch(&dispatch, "/tmp/project"); + let actor_env = command + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(animus_runtime_shared::actor_env::ANIMUS_ACTOR_JSON_ENV)) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + .expect("owner-scoped dispatch must set the actor env"); + let decoded = animus_runtime_shared::actor_env::decode_actor_env(Some(&actor_env)).expect("decodes"); + assert_eq!(decoded.user_id, "alice"); + assert_eq!(decoded.claims, vec!["admin".to_string()]); + } + + #[test] + fn runner_command_clears_actor_env_for_global_dispatch() { + // A global dispatch must explicitly CLEAR the env so the runner never + // inherits the daemon's own ANIMUS_ACTOR_JSON (env_remove records a + // removal on the Command). + let dispatch = SubjectDispatch::for_custom("schedule:global", "wf", "schedule", None, "schedule"); + let command = build_runner_command_from_dispatch(&dispatch, "/tmp/project"); + let entry = command + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(animus_runtime_shared::actor_env::ANIMUS_ACTOR_JSON_ENV)); + assert_eq!( + entry, + Some((std::ffi::OsStr::new(animus_runtime_shared::actor_env::ANIMUS_ACTOR_JSON_ENV), None)), + "global dispatch must clear (remove) the actor env, not set it" + ); + } + #[test] fn plugin_workflow_runner_binary_name_uses_default_suffix() { // As of v0.5.1 round-4 fold-in the resolver prefers the diff --git a/crates/orchestrator-daemon-runtime/src/schedule/schedule_dispatch.rs b/crates/orchestrator-daemon-runtime/src/schedule/schedule_dispatch.rs index 7ff724f5..4884d8a3 100644 --- a/crates/orchestrator-daemon-runtime/src/schedule/schedule_dispatch.rs +++ b/crates/orchestrator-daemon-runtime/src/schedule/schedule_dispatch.rs @@ -122,7 +122,8 @@ where workflow_ref.clone(), schedule.input.clone(), trigger_source.to_string(), - ); + ) + .with_actor(mint_schedule_actor(schedule)); match spawn_pipeline(schedule_id, &dispatch) { Ok(()) => "dispatched".to_string(), Err(error) => { @@ -147,6 +148,23 @@ where status } +/// Mint the [`Actor`] an owner-scoped schedule runs as, or `None` for a global +/// (system) schedule. +/// +/// TRUST BOUNDARY: this is the ONE place the kernel CONSTRUCTS an actor rather +/// than relaying a transport-asserted one. It is sound because the owner is +/// asserted at config-authoring time: the workflow config (and thus +/// `schedule.owner_id`) is itself owner-scoped / admin-authored — served by a +/// trusted `config_source` (e.g. `config-postgres` team_* rows or +/// admin-curated YAML), NEVER derived from runtime, agent output, or subject +/// content. Minting the owner here therefore respects the +/// transport-asserted-identity model. A schedule with no `owner_id` keeps the +/// legacy global dispatch (`actor = None`). +fn mint_schedule_actor(schedule: &orchestrator_core::workflow_config::WorkflowSchedule) -> Option { + let owner_id = schedule.owner_id.as_deref().map(str::trim).filter(|id| !id.is_empty())?; + Some(animus_actor::Actor { user_id: owner_id.to_string(), claims: schedule.claims.clone(), tenant_id: None }) +} + /// `occurrence_allowed` is the active-hours gate applied to the caught-up /// occurrence itself: an occurrence that fell inside a closed `active_hours` /// window (but within the catch-up horizon) must be skipped, not replayed @@ -359,6 +377,8 @@ mod tests { command: None, input: None, enabled: false, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -376,6 +396,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -393,6 +415,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -410,6 +434,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -427,6 +453,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let mut state = orchestrator_core::ScheduleState::default(); state.schedules.insert( @@ -457,6 +485,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let mut state = orchestrator_core::ScheduleState::default(); state.schedules.insert( @@ -493,6 +523,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let mut state = orchestrator_core::ScheduleState::default(); state.schedules.insert( @@ -528,6 +560,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let mut state = orchestrator_core::ScheduleState::default(); state.schedules.insert( @@ -558,6 +592,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let mut state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -590,6 +626,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let due = evaluate_schedules(&schedules, &state, now, |_| true); @@ -624,6 +662,8 @@ mod tests { command: None, input: Some(json!({"scope":"nightly"})), enabled: true, + owner_id: None, + claims: Vec::new(), }); orchestrator_core::write_workflow_config(project_root, &config).expect("workflow config should be written"); @@ -682,6 +722,8 @@ mod tests { command: Some("echo cleanup".to_string()), input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }; let pipeline_calls = Arc::new(Mutex::new(Vec::new())); let pipeline_calls_ref = pipeline_calls.clone(); @@ -700,6 +742,74 @@ mod tests { assert!(calls.is_empty()); } + #[test] + fn owner_scoped_schedule_dispatches_minted_actor() { + let now: chrono::DateTime = "2026-03-04T12:30:00Z".parse().expect("timestamp should parse"); + let schedule = orchestrator_core::WorkflowSchedule { + id: "nightly".to_string(), + cron: "30 12 * * *".to_string(), + workflow_ref: Some("standard-workflow".to_string()), + command: None, + input: None, + enabled: true, + owner_id: Some("alice".to_string()), + claims: vec!["admin".to_string()], + }; + let captured = Arc::new(Mutex::new(None)); + let captured_ref = captured.clone(); + let mut spawn = move |_id: &str, dispatch: &SubjectDispatch| { + *captured_ref.lock().expect("lock") = dispatch.actor.clone(); + Ok(()) + }; + + let status = dispatch_schedule("nightly", &schedule, now, "schedule", &mut spawn); + assert_eq!(status, "dispatched"); + let actor = captured.lock().expect("lock").clone().expect("owner schedule must mint an actor"); + assert_eq!(actor.user_id, "alice"); + assert_eq!(actor.claims, vec!["admin".to_string()]); + assert_eq!(actor.tenant_id, None); + } + + #[test] + fn global_schedule_dispatches_without_actor() { + let now: chrono::DateTime = "2026-03-04T12:30:00Z".parse().expect("timestamp should parse"); + let schedule = orchestrator_core::WorkflowSchedule { + id: "global".to_string(), + cron: "30 12 * * *".to_string(), + workflow_ref: Some("standard-workflow".to_string()), + command: None, + input: None, + enabled: true, + owner_id: None, + claims: Vec::new(), + }; + let captured = Arc::new(Mutex::new(Some(animus_actor::Actor::new("sentinel")))); + let captured_ref = captured.clone(); + let mut spawn = move |_id: &str, dispatch: &SubjectDispatch| { + *captured_ref.lock().expect("lock") = dispatch.actor.clone(); + Ok(()) + }; + + let status = dispatch_schedule("global", &schedule, now, "schedule", &mut spawn); + assert_eq!(status, "dispatched"); + assert!(captured.lock().expect("lock").is_none(), "a schedule without owner_id must stay global (actor=None)"); + } + + #[test] + fn owner_id_whitespace_only_stays_global() { + let schedule = orchestrator_core::WorkflowSchedule { + id: "blank-owner".to_string(), + cron: "* * * * *".to_string(), + workflow_ref: Some("wf".to_string()), + command: None, + input: None, + enabled: true, + owner_id: Some(" ".to_string()), + claims: Vec::new(), + }; + assert!(mint_schedule_actor(&schedule).is_none(), "a blank owner_id must not mint an actor"); + } + fn deadline_schedule(id: &str, cron: &str, enabled: bool) -> orchestrator_core::WorkflowSchedule { orchestrator_core::WorkflowSchedule { id: id.to_string(), @@ -708,6 +818,8 @@ mod tests { command: None, input: None, enabled, + owner_id: None, + claims: Vec::new(), } } @@ -792,6 +904,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }); orchestrator_core::write_workflow_config(project_root, &config).expect("workflow config should be written"); @@ -806,6 +920,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }); orchestrator_core::write_workflow_config(project_root, &config).expect("workflow config should be rewritten"); @@ -868,6 +984,8 @@ mod tests { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }]; let state = orchestrator_core::ScheduleState::default(); let now: chrono::DateTime = "2026-03-07T14:00:00Z".parse().unwrap(); diff --git a/crates/orchestrator-daemon-runtime/tests/tick_budget_joint.rs b/crates/orchestrator-daemon-runtime/tests/tick_budget_joint.rs index 1f61f5ba..96ef92d2 100644 --- a/crates/orchestrator-daemon-runtime/tests/tick_budget_joint.rs +++ b/crates/orchestrator-daemon-runtime/tests/tick_budget_joint.rs @@ -54,6 +54,8 @@ fn write_combined_config(project_root: &std::path::Path) { command: None, input: None, enabled: true, + owner_id: None, + claims: Vec::new(), }); config.triggers.push(orchestrator_core::workflow_config::WorkflowTrigger { id: "on-webhook".to_string(), From cf8624fec7189accfa75675ab2f756edd8f026ec Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 15:19:15 -0600 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v0.6.22=20=E2=80=94=20WU-?= =?UTF-8?q?F=20owner-scoped=20schedules=20+=20codex-p1=20isolated=20MCP=20?= =?UTF-8?q?config=20(protocol=20v0.1.26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 23 ++++++++++++----------- crates/orchestrator-cli/Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7043d355..87027a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "animus-actor" version = "0.1.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "schemars", "serde", @@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "animus-config-protocol" version = "0.1.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-actor", "anyhow", @@ -94,7 +94,7 @@ dependencies = [ [[package]] name = "animus-control-protocol" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-actor", "animus-log-storage-protocol", @@ -114,7 +114,7 @@ dependencies = [ [[package]] name = "animus-log-storage-protocol" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-plugin-protocol 0.1.15", "anyhow", @@ -176,7 +176,7 @@ dependencies = [ [[package]] name = "animus-plugin-protocol" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "schemars", "serde", @@ -241,7 +241,7 @@ dependencies = [ [[package]] name = "animus-session-backend" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-actor", "animus-plugin-protocol 0.1.15", @@ -274,8 +274,9 @@ dependencies = [ [[package]] name = "animus-subject-protocol" version = "0.1.16" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ + "animus-actor", "animus-plugin-protocol 0.1.15", "anyhow", "async-trait", @@ -290,7 +291,7 @@ dependencies = [ [[package]] name = "animus-trigger-protocol" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-plugin-protocol 0.1.15", "anyhow", @@ -306,7 +307,7 @@ dependencies = [ [[package]] name = "animus-workflow-runner-protocol" version = "0.2.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-actor", "animus-plugin-protocol 0.1.15", @@ -2763,7 +2764,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchestrator-cli" -version = "0.6.21" +version = "0.6.22" dependencies = [ "animus-actor", "animus-control-protocol", @@ -3141,7 +3142,7 @@ dependencies = [ [[package]] name = "protocol" version = "0.1.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.26#a6e37ed730204449dfe61ff4f413d7bd3bec2401" dependencies = [ "animus-subject-protocol 0.1.16", "anyhow", diff --git a/crates/orchestrator-cli/Cargo.toml b/crates/orchestrator-cli/Cargo.toml index 28c9638d..c60a8970 100644 --- a/crates/orchestrator-cli/Cargo.toml +++ b/crates/orchestrator-cli/Cargo.toml @@ -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"