From 753cddc1a0b39f4f32c1e412f212e98210aed119 Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 10:10:51 -0600 Subject: [PATCH 1/3] feat(actor): thread per-user Actor through kernel + re-key compiled-config cache Repin all v0.1.24 animus-protocol git deps to v0.1.25 and add the new zero-dep `animus-actor` crate. v0.1.25 additively adds `actor: Option` to config/control/runner/session request types. control-protocol (v0.1.13 -> v0.1.25), workflow-runner-protocol (v0.5.3 -> v0.1.25) and subject-protocol-wire move with it (wire must share control's subject-protocol rev); queue + subject-protocol-v05 stay on the matched v0.5.10 pair. WU-C: thread `actor: Option<&Actor>` through resolve_plugin_base -> load_via_resident -> config_load_call (serialized into config/load params) and load_workflow_config[_with_metadata]. Re-key the compiled-config cache by (project_root, ActorCacheKey) and fold the actor key into compile_token so one user's compiled config can never be served to another; invalidate_compiled clears every actor partition for a root. ActorCacheKey uses unambiguous JSON (no delimiter-collision between distinct identities). WU-D: relay actor through the control surface (struct passthrough) and populate the runner WorkflowExecuteRequest.actor in execute.rs. The daemon control path spawns a detached runner child that rebuilds its request from the persisted record, so the authenticated actor is relayed to that child via a trusted daemon->child env handoff (WORKFLOW_ACTOR_ENV) carried in the runner overrides. TRUST BOUNDARY: the actor is set ONLY from the authenticated control request; never synthesized from local CLI context, workflow YAML, agent output, or subject content. Tests: per-actor cache isolation (no cross-leak), actor=None == global, invalidate clears all partitions, claim-order independence, delimiter-collision resistance, and the detached-runner actor env round-trip. --- Cargo.lock | 132 ++++------- Cargo.toml | 13 +- crates/animus-mcp-oauth/src/config.rs | 2 +- crates/animus-plugin-runtime/src/lib.rs | 4 + crates/orchestrator-cli/Cargo.toml | 31 +-- .../operations/ops_mcp/interaction_tools.rs | 6 + .../operations/ops_workflow/config.rs | 6 +- .../ops_workflow/control_routing.rs | 32 ++- .../operations/ops_workflow/execute.rs | 17 ++ .../services/operations/ops_workflow/mod.rs | 21 +- .../operations/ops_workflow/phases.rs | 96 +++++++- .../operations/ops_workflow/prompt.rs | 2 +- .../runtime/runtime_agent/provider_client.rs | 5 + .../src/services/runtime/runtime_chat/turn.rs | 3 + .../tests/plugin_agent_run_e2e.rs | 2 + crates/orchestrator-config/Cargo.toml | 3 + .../src/agent_runtime_config.rs | 5 +- .../orchestrator-config/src/pack_registry.rs | 16 +- .../workflow_config/config_source_client.rs | 217 +++++++++++++++--- .../src/workflow_config/config_write.rs | 5 +- .../src/workflow_config/loading.rs | 33 ++- .../src/workflow_config/tests.rs | 8 +- .../orchestrator-core/src/services/tests.rs | 12 +- .../src/workflow/phase_plan.rs | 2 +- crates/orchestrator-daemon-runtime/Cargo.toml | 26 +-- .../src/session/plugin_backend.rs | 8 + .../src/session/session_backend_resolver.rs | 1 + .../tests/plugin_supervisor.rs | 1 + 28 files changed, 517 insertions(+), 192 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 644b3657..b1d13710 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,11 +67,21 @@ dependencies = [ "libc", ] +[[package]] +name = "animus-actor" +version = "0.1.0" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" +dependencies = [ + "schemars", + "serde", +] + [[package]] name = "animus-config-protocol" version = "0.1.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.24#9bb711498a1c7958e75d0a3743b8905c4e2317e9" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ + "animus-actor", "anyhow", "protocol", "schemars", @@ -83,12 +93,13 @@ dependencies = [ [[package]] name = "animus-control-protocol" -version = "0.1.13" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.13#1dc37a0db952e3805a28a904b9f6120837584752" +version = "0.1.15" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ + "animus-actor", "animus-log-storage-protocol", - "animus-plugin-protocol 0.1.13", - "animus-subject-protocol 0.1.13", + "animus-plugin-protocol 0.1.15", + "animus-subject-protocol 0.1.16", "animus-trigger-protocol", "anyhow", "async-trait", @@ -102,14 +113,15 @@ dependencies = [ [[package]] name = "animus-log-storage-protocol" -version = "0.1.13" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.13#1dc37a0db952e3805a28a904b9f6120837584752" +version = "0.1.15" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ - "animus-plugin-protocol 0.1.13", + "animus-plugin-protocol 0.1.15", "anyhow", "async-trait", "chrono", "futures-core", + "schemars", "serde", "serde_json", "thiserror 1.0.69", @@ -151,15 +163,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "animus-plugin-protocol" -version = "0.1.13" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.13#1dc37a0db952e3805a28a904b9f6120837584752" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "animus-plugin-protocol" version = "0.1.14" @@ -170,20 +173,10 @@ dependencies = [ "serde_json", ] -[[package]] -name = "animus-plugin-protocol" -version = "0.1.14" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.3#6b114186adc16a2b59a47702b578c0138a33241c" -dependencies = [ - "schemars", - "serde", - "serde_json", -] - [[package]] name = "animus-plugin-protocol" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.24#9bb711498a1c7958e75d0a3743b8905c4e2317e9" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ "schemars", "serde", @@ -209,7 +202,7 @@ name = "animus-queue-protocol" version = "0.3.2" source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.10#a3b39dcbe02e8c3701f85b89aca55ae010193620" dependencies = [ - "animus-plugin-protocol 0.1.14 (git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.10)", + "animus-plugin-protocol 0.1.14", "animus-subject-protocol 0.1.15", "schemars", "serde", @@ -247,8 +240,9 @@ dependencies = [ [[package]] name = "animus-session-backend" version = "0.1.15" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.24#9bb711498a1c7958e75d0a3743b8905c4e2317e9" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ + "animus-actor", "animus-plugin-protocol 0.1.15", "anyhow", "async-trait", @@ -260,43 +254,12 @@ dependencies = [ "uuid", ] -[[package]] -name = "animus-subject-protocol" -version = "0.1.13" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.13#1dc37a0db952e3805a28a904b9f6120837584752" -dependencies = [ - "animus-plugin-protocol 0.1.13", - "anyhow", - "async-trait", - "chrono", - "futures-core", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "animus-subject-protocol" -version = "0.1.14" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.3#6b114186adc16a2b59a47702b578c0138a33241c" -dependencies = [ - "animus-plugin-protocol 0.1.14 (git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.3)", - "anyhow", - "async-trait", - "chrono", - "futures-core", - "schemars", - "serde", - "serde_json", - "thiserror 1.0.69", -] - [[package]] name = "animus-subject-protocol" version = "0.1.15" source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.10#a3b39dcbe02e8c3701f85b89aca55ae010193620" dependencies = [ - "animus-plugin-protocol 0.1.14 (git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.10)", + "animus-plugin-protocol 0.1.14", "anyhow", "async-trait", "chrono", @@ -310,7 +273,7 @@ dependencies = [ [[package]] name = "animus-subject-protocol" version = "0.1.16" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.24#9bb711498a1c7958e75d0a3743b8905c4e2317e9" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ "animus-plugin-protocol 0.1.15", "anyhow", @@ -325,14 +288,15 @@ dependencies = [ [[package]] name = "animus-trigger-protocol" -version = "0.1.13" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.13#1dc37a0db952e3805a28a904b9f6120837584752" +version = "0.1.15" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ - "animus-plugin-protocol 0.1.13", + "animus-plugin-protocol 0.1.15", "anyhow", "async-trait", "chrono", "futures-core", + "schemars", "serde", "serde_json", "thiserror 1.0.69", @@ -341,10 +305,11 @@ dependencies = [ [[package]] name = "animus-workflow-runner-protocol" version = "0.2.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.3#6b114186adc16a2b59a47702b578c0138a33241c" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ - "animus-plugin-protocol 0.1.14 (git+https://github.com/launchapp-dev/animus-protocol?tag=v0.5.3)", - "animus-subject-protocol 0.1.14", + "animus-actor", + "animus-plugin-protocol 0.1.15", + "animus-subject-protocol 0.1.16", "schemars", "serde", "serde_json", @@ -386,7 +351,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -397,7 +362,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1336,7 +1301,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1435,7 +1400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2602,7 +2567,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2799,6 +2764,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" name = "orchestrator-cli" version = "0.6.19" dependencies = [ + "animus-actor", "animus-control-protocol", "animus-log-storage-protocol", "animus-mcp-oauth", @@ -2806,8 +2772,8 @@ dependencies = [ "animus-queue-protocol", "animus-runtime-shared", "animus-session-backend", - "animus-subject-protocol 0.1.13", "animus-subject-protocol 0.1.15", + "animus-subject-protocol 0.1.16", "animus-workflow-runner-protocol", "anyhow", "assert_cmd", @@ -2849,6 +2815,7 @@ dependencies = [ name = "orchestrator-config" version = "0.1.0" dependencies = [ + "animus-actor", "animus-config-protocol", "anyhow", "croner", @@ -2905,11 +2872,12 @@ dependencies = [ name = "orchestrator-daemon-runtime" version = "0.1.0" dependencies = [ + "animus-actor", "animus-control-protocol", "animus-log-storage-protocol", "animus-plugin-protocol 0.1.0", "animus-runtime-shared", - "animus-subject-protocol 0.1.13", + "animus-subject-protocol 0.1.16", "anyhow", "arc-swap", "async-trait", @@ -3171,7 +3139,7 @@ dependencies = [ [[package]] name = "protocol" version = "0.1.0" -source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.24#9bb711498a1c7958e75d0a3743b8905c4e2317e9" +source = "git+https://github.com/launchapp-dev/animus-protocol?tag=v0.1.25#e9bf4f1575f68ed0ffe59de5507d1d0e9350a533" dependencies = [ "animus-subject-protocol 0.1.16", "anyhow", @@ -3604,7 +3572,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3662,7 +3630,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4034,7 +4002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4174,7 +4142,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4578,7 +4546,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 57fad9ca..275eee88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,16 +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.24) so transitive +# animus-protocol git deps are pinned to the SAME tag (v0.1.25) 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-config-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.24" } -animus-provider-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.24" } -animus-session-backend = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.24" } -animus-subject-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.24" } -protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.24", package = "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" } 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-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 48f164f3..b67a95ad 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -107,7 +107,7 @@ pub fn resolve_server_url( // case. So: only propagate the load error when a workflow YAML source // actually exists; otherwise fall back to the builtin default and continue // to project config. - let loaded = match load_workflow_config_with_metadata(project_root) { + let loaded = match load_workflow_config_with_metadata(project_root, None) { Ok(loaded) => loaded, Err(err) if workflow_yaml_present(project_root) => { return Err(ServerResolutionError::WorkflowConfig(err.to_string())); diff --git a/crates/animus-plugin-runtime/src/lib.rs b/crates/animus-plugin-runtime/src/lib.rs index 9c069ac2..bdd81d7d 100644 --- a/crates/animus-plugin-runtime/src/lib.rs +++ b/crates/animus-plugin-runtime/src/lib.rs @@ -643,6 +643,10 @@ fn build_session_request(info: &ProviderInfo, params: AgentRunParams) -> Session env_vars: params.env.into_iter().collect(), mcp_servers: params.mcp_servers, extras: Value::Object(extras), + // `AgentRunParams` carries no actor today; the actor reaches a provider + // session only when relayed from the workflow runner (out-of-tree). Never + // synthesized from local run params. + actor: None, } } diff --git a/crates/orchestrator-cli/Cargo.toml b/crates/orchestrator-cli/Cargo.toml index cdf86e07..d4ecccbc 100644 --- a/crates/orchestrator-cli/Cargo.toml +++ b/crates/orchestrator-cli/Cargo.toml @@ -29,20 +29,25 @@ orchestrator-core = { path = "../orchestrator-core" } orchestrator-logging = { path = "../orchestrator-logging" } orchestrator-plugin-host = { workspace = true } animus-plugin-protocol = { workspace = true } -animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -animus-log-storage-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -animus-subject-protocol-wire = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -# v0.5 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 transitively -# pulled `animus-subject-protocol` (v0.5.0 git source) is the canonical -# `SubjectDispatch` identity; `protocol::SubjectDispatch` re-exports it. -# Wire formats remain byte-for-byte compatible with the v0.1.13 surface -# per the Wave 1 handoff state. +# v0.1.x-lineage protocol crates. Pinned to v0.1.25 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 + +# 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-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 +# 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.5.3" } -# v0.5.10 (not v0.5.3): must share the same subject-protocol revision as -# animus-queue-protocol so SubjectId unifies in QueueLeaseRequest.exclude_subjects. +animus-workflow-runner-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" } 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-cli/src/services/operations/ops_mcp/interaction_tools.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs index 4267a7b8..22ec75a7 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs @@ -600,6 +600,8 @@ async fn evaluate_approval_with_llm( env_vars: Vec::new(), mcp_servers: None, extras: json!({ "system_prompt": build_judge_system_prompt(extra_instructions) }), + // Internal approval-judge session: no caller actor is propagated here. + actor: None, }; let mut run = match provider_client::start_session(std::path::Path::new(project_root), request).await { @@ -756,6 +758,8 @@ async fn answer_question_with_llm( env_vars: Vec::new(), mcp_servers: None, extras: json!({ "system_prompt": system }), + // Internal autopilot judge session: no caller actor is propagated here. + actor: None, }; let mut run = match provider_client::start_session(std::path::Path::new(project_root), request).await { @@ -881,6 +885,8 @@ async fn answer_structured_with_llm( env_vars: Vec::new(), mcp_servers: None, extras: json!({ "system_prompt": system }), + // Internal autopilot judge session: no caller actor is propagated here. + actor: None, }; let mut run = match provider_client::start_session(std::path::Path::new(project_root), request).await { Ok(run) => run, diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/config.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/config.rs index d35e3c7c..afe0ceee 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/config.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/config.rs @@ -181,7 +181,7 @@ pub(crate) fn set_agent_runtime_payload(project_root: &str, input_json: &str) -> pub(crate) fn get_workflow_config_payload(project_root: &str) -> Value { let path = workflow_config_path(project_root); - match orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root)) { + match orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root), None) { Ok(loaded) => serde_json::json!({ "path": path.display().to_string(), "source": loaded.metadata.source, @@ -232,7 +232,7 @@ fn config_error_to_value(error: &anyhow::Error) -> Value { } pub(crate) fn validate_workflow_config_payload(project_root: &str) -> Value { - let workflow_loaded = orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root)); + let workflow_loaded = orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root), None); let runtime_loaded = orchestrator_core::agent_runtime_config::load_agent_runtime_config_with_metadata(Path::new(project_root)); let warnings = unenforced_warnings_payload(project_root); @@ -512,7 +512,7 @@ pub(crate) fn compile_yaml_workflows_payload(project_root: &str) -> Result Result { let written_hash = orchestrator_core::workflow_config_hash(config); - let loaded = orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root)).with_context(|| { + let loaded = orchestrator_core::load_workflow_config_with_metadata(Path::new(project_root), None).with_context(|| { "config was written but reloading it from the config_source plugin failed; the persisted config may be unusable" })?; Ok(serde_json::json!({ diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/control_routing.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/control_routing.rs index dba28458..1ce3bdcd 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/control_routing.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/control_routing.rs @@ -157,6 +157,9 @@ fn extract_runner_overrides_from_params( model: obj.get("model").and_then(serde_json::Value::as_str).map(ToOwned::to_owned), tool: obj.get("tool").and_then(serde_json::Value::as_str).map(ToOwned::to_owned), phase_timeout_secs: obj.get("phase_timeout_secs").and_then(serde_json::Value::as_u64), + // The actor is NOT carried in `params.overrides` (untrusted wire field); + // the caller sets `overrides.actor` from the authenticated `request.actor`. + actor: None, } } @@ -198,7 +201,27 @@ impl WorkflowRouting for WorkflowRoutingImpl { // path. Any non-string values are stringified via // `Value::to_string()` so callers always see a valid scalar. let vars = extract_vars_from_params(&request.params); - let overrides = extract_runner_overrides_from_params(&request.params); + let mut overrides = extract_runner_overrides_from_params(&request.params); + // TRUST BOUNDARY: `request.actor` is the transport-asserted caller + // identity from the authenticated control request — the ONLY trusted + // source. The kernel relays it opaquely; it never reads an actor from + // workflow config, agent output, or subject content. This path hands + // execution to a DETACHED workflow_runner child that rebuilds its + // request from the persisted record (which carries no actor), so the + // actor is relayed to that child via the trusted WORKFLOW_ACTOR_ENV + // handoff carried in the runner overrides. + // + // TODO(codex-p2): the actor reaches the detached RUNNER, but the core + // bootstrap below (`start_workflow_with_runner` -> `hub.workflows().run`) + // still resolves the default-workflow-ref / skip-guards / phase plan from + // `load_workflow_config_or_default(actor=None)` (the global config + // partition). For an actor-scoped `config_source` (per-user default + // workflow or actor-only definitions) the record can be created from the + // global view. Closing this needs `actor` threaded through the core + // `WorkflowServiceApi::run` + phase-plan resolution (a cross-crate change + // beyond this wave's kernel-threading scope); tracked for the + // runner-integration / per-actor-config wave. + overrides.actor = request.actor; let input = WorkflowRunInput::for_task(request.task_id, request.definition).with_vars(vars); // Post-v0.5 the daemon has no in-process workflow executor: a bare // `workflows.run(...)` would only bootstrap a Running record that @@ -227,7 +250,12 @@ impl WorkflowRouting for WorkflowRoutingImpl { )); } let vars = extract_vars_from_params(&request.params); - let overrides = extract_runner_overrides_from_params(&request.params); + let mut overrides = extract_runner_overrides_from_params(&request.params); + // TRUST BOUNDARY: see `workflow_run` above — `request.actor` is relayed + // opaquely from the authenticated control request only, and reaches the + // detached workflow_runner child via the trusted WORKFLOW_ACTOR_ENV + // handoff carried in the runner overrides. + overrides.actor = request.actor; let input = WorkflowRunInput::for_task(task_id, Some(request.definition)).with_vars(vars); let workflow = super::phases::start_workflow_with_runner(hub, &self.project_root_str(), input, overrides) .await diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/execute.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/execute.rs index 67e72256..35e0a4cb 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/execute.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/execute.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use animus_actor::Actor; use anyhow::{anyhow, Result}; use orchestrator_core::services::ServiceHub; @@ -22,6 +23,15 @@ pub(crate) struct WorkflowExecuteArgs { pub(crate) phase_timeout_secs: Option, pub(crate) input_json: Option, pub(crate) vars: Vec, + /// Transport-asserted caller identity, relayed verbatim into the runner + /// `WorkflowExecuteRequest` so the runner can scope subject/journal/config + /// plugins to the user. + /// + /// TRUST BOUNDARY: this is populated ONLY from an authenticated inbound + /// control request. Local CLI invocations leave it `None` — the actor is + /// NEVER synthesized from local context, workflow YAML, agent output, or + /// subject content. + pub(crate) actor: Option, } pub(crate) async fn handle_workflow_execute( @@ -97,6 +107,10 @@ pub(crate) async fn handle_workflow_execute( request.tool = args.tool.clone(); request.phase_timeout_secs = args.phase_timeout_secs; request.phase_filter = phase_filter.clone(); + // Relay the transport-asserted actor (if any) verbatim. The persisted + // record carries no actor, so this is the only place a re-attach run + // can carry the caller identity. + request.actor = args.actor.clone(); request } else { workflow_proto::WorkflowExecuteRequest { @@ -116,6 +130,9 @@ pub(crate) async fn handle_workflow_execute( phase_filter: phase_filter.clone(), phase_routing: None, mcp_config: None, + // Relayed verbatim from the authenticated control request; `None` + // for local CLI invocations (never synthesized). + actor: args.actor.clone(), } }; let project_root_path = std::path::Path::new(project_root); diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/mod.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/mod.rs index 202b3782..0aa1be76 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/mod.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/mod.rs @@ -335,7 +335,7 @@ pub(crate) fn resolve_requirement_workflow_ref(project_root: &str) -> Result `None` (never + // synthesized from local context, YAML, or subject content). + actor: phases::workflow_actor_from_env(), }; execute::handle_workflow_execute(execute_args, hub, project_root, json).await?; Ok(()) @@ -484,6 +492,9 @@ pub(crate) async fn handle_workflow( model: args.model.clone(), tool: args.tool.clone(), phase_timeout_secs: args.phase_timeout_secs, + // Local async `workflow run`: no authenticated transport + // actor. The control path supplies the actor instead. + actor: None, }; if json && args.input_json.is_none() && args.requirement_id.is_none() && args.title.is_none() { if let Some(task_id) = args.task_id.as_ref() { @@ -712,7 +723,7 @@ pub(crate) async fn handle_workflow( }, WorkflowCommand::Definitions { command } => match command { WorkflowDefinitionsCommand::List => { - let wf_config = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let wf_config = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; print_value(wf_config.workflows, json) } WorkflowDefinitionsCommand::Upsert(args) => { @@ -892,7 +903,11 @@ async fn try_workflow_run_via_control( } params.insert("overrides".to_string(), serde_json::Value::Object(overrides_obj)); } - let request = WireRequest { task_id: task_id.to_string(), definition, params }; + // Local CLI → own daemon over the control wire: no authenticated transport + // actor (the operator is acting locally). Authenticated actors are asserted + // only by the transport plugins (http/graphql) on inbound requests — never + // synthesized by the CLI client. + let request = WireRequest { task_id: task_id.to_string(), definition, params, actor: None }; match client.workflow_run(request).await { Ok(response) => Ok(Some(response)), Err(err) if is_method_unavailable(&err) => { diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs index fa0b85cb..c552ff95 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::sync::Arc; +use animus_actor::Actor; use anyhow::{anyhow, Context, Result}; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -97,6 +98,10 @@ pub(crate) fn workflow_execute_request_for_existing( phase_filter: None, phase_routing: None, mcp_config: None, + // The persisted record carries no actor. Callers that have a + // transport-asserted actor (the re-attach path in `execute.rs`) + // overwrite `request.actor` after building. Never synthesized here. + actor: None, } } @@ -108,6 +113,38 @@ pub(crate) struct DetachedRunnerOverrides { pub(crate) model: Option, pub(crate) tool: Option, pub(crate) phase_timeout_secs: Option, + /// Transport-asserted caller identity from the authenticated control + /// request, relayed to the detached `workflow run --sync` child via + /// [`WORKFLOW_ACTOR_ENV`] so the runner request the child builds carries it. + /// `None` for local CLI / system-initiated runs. + pub(crate) actor: Option, +} + +/// Daemon → detached-runner-child handoff for the transport-asserted [`Actor`]. +/// +/// TRUST BOUNDARY: this env var is set on the spawned child ONLY by the daemon +/// (in `detached_runner_command`) from the authenticated control request's +/// actor — the same trust as the daemon spawning the child at all. The child +/// reads it back in the `workflow run --sync` path. It is process-environment +/// (a parent→child channel like secrets), NOT workflow YAML, agent output, or +/// subject content, so it does not widen the actor's trusted sources. A local +/// operator who sets it is merely asserting their own identity, which carries no +/// kernel privilege (claims are advisory; consumers enforce). +pub(crate) const WORKFLOW_ACTOR_ENV: &str = "ANIMUS_WORKFLOW_ACTOR_JSON"; + +/// Read the relayed [`Actor`] from [`WORKFLOW_ACTOR_ENV`], if the daemon set it +/// on this (detached-runner-child) process. A malformed value is ignored +/// (logged) rather than failing the run — a dropped actor degrades to global +/// scope, never a crash. +pub(crate) fn workflow_actor_from_env() -> Option { + let raw = std::env::var(WORKFLOW_ACTOR_ENV).ok()?; + match serde_json::from_str::(&raw) { + Ok(actor) => Some(actor), + Err(error) => { + tracing::warn!(%error, "ignoring malformed {WORKFLOW_ACTOR_ENV}; running with no actor"); + None + } + } } fn detached_runner_command( @@ -133,6 +170,20 @@ fn detached_runner_command( if let Some(timeout) = overrides.phase_timeout_secs { command.args(["--phase-timeout-secs", &timeout.to_string()]); } + // Relay the authenticated actor to the detached child via a trusted env var + // (see WORKFLOW_ACTOR_ENV). The child rebuilds its runner request from the + // persisted record (which carries no actor), so this is the only channel + // that delivers the caller identity to the detached run. + if let Some(actor) = overrides.actor.as_ref() { + match serde_json::to_string(actor) { + Ok(json) => { + command.env(WORKFLOW_ACTOR_ENV, json); + } + Err(error) => { + tracing::warn!(%error, "failed to encode actor for detached runner; run proceeds with no actor"); + } + } + } command .current_dir(project_root) .stdin(std::process::Stdio::null()) @@ -379,7 +430,7 @@ pub(crate) fn upsert_phase_definition( phase_id: &str, definition: orchestrator_core::PhaseExecutionDefinition, ) -> Result { - let mut workflow = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let mut workflow = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; let catalog_entry = workflow.phase_catalog.keys().all(|existing| !existing.eq_ignore_ascii_case(phase_id)).then(|| { orchestrator_core::PhaseUiDefinition { @@ -424,7 +475,7 @@ pub(crate) fn upsert_phase_definition( } pub(crate) fn remove_phase_definition(project_root: &str, phase_id: &str) -> Result { - let workflow = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let workflow = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; // TODO(codex-p2): when the phase is a generated-overlay OVERRIDE of a // hand-authored YAML/pack definition, removing the override leaves the // phase defined and pipeline references valid — this check should not @@ -495,7 +546,7 @@ pub(crate) fn preview_phase_removal(project_root: &str, phase_id: &str) -> Resul } pub(crate) fn upsert_pipeline(project_root: &str, pipeline: orchestrator_core::WorkflowDefinition) -> Result { - let mut workflow = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let mut workflow = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; if let Some(existing) = workflow.workflows.iter_mut().find(|existing| existing.id.eq_ignore_ascii_case(pipeline.id.as_str())) { @@ -522,7 +573,7 @@ pub(crate) fn upsert_pipeline(project_root: &str, pipeline: orchestrator_core::W } pub(crate) fn phase_payload(project_root: &str, phase_id: &str) -> Result { - let workflow = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let workflow = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; let runtime = orchestrator_core::load_agent_runtime_config(Path::new(project_root))?; let ui = @@ -538,7 +589,7 @@ pub(crate) fn phase_payload(project_root: &str, phase_id: &str) -> Result } pub(crate) fn list_phase_payload(project_root: &str) -> Result { - let workflow = orchestrator_core::load_workflow_config(Path::new(project_root))?; + let workflow = orchestrator_core::load_workflow_config(Path::new(project_root), None)?; let runtime = orchestrator_core::load_agent_runtime_config(Path::new(project_root))?; let mut phases = Vec::new(); @@ -919,7 +970,7 @@ workflows: let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let recompiled = orchestrator_core::load_workflow_config(temp.path()).expect("recompile should succeed"); + let recompiled = orchestrator_core::load_workflow_config(temp.path(), None).expect("recompile should succeed"); assert!(recompiled.phase_definitions.contains_key("custom-phase"), "phase should survive recompile"); let runtime = load_agent_runtime_config(temp.path()).expect("runtime should load"); assert!(runtime.phases.contains_key("custom-phase"), "phase should reach the runtime config"); @@ -955,7 +1006,7 @@ workflows: let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let recompiled = orchestrator_core::load_workflow_config(temp.path()).expect("recompile should succeed"); + let recompiled = orchestrator_core::load_workflow_config(temp.path(), None).expect("recompile should succeed"); assert!( recompiled.workflows.iter().any(|workflow| workflow.id == "custom-pipeline"), "pipeline should survive recompile" @@ -1244,6 +1295,7 @@ workflows: model: Some("claude-sonnet-4-6".to_string()), tool: Some("claude".to_string()), phase_timeout_secs: Some(120), + actor: None, }, ); let args: Vec = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); @@ -1267,6 +1319,36 @@ workflows: ], "async-run execution overrides must be forwarded to the detached child" ); + // No actor => the trusted actor-handoff env var must be absent. + assert!( + !command.get_envs().any(|(k, _)| k == std::ffi::OsStr::new(super::WORKFLOW_ACTOR_ENV)), + "actor env must not be set when no actor is present" + ); + } + + #[test] + fn detached_runner_command_relays_actor_via_trusted_env() { + // The authenticated control-request actor reaches the detached child + // through WORKFLOW_ACTOR_ENV (the persisted record carries no actor), + // round-tripping losslessly so the child rebuilds a scoped runner request. + let actor = animus_actor::Actor { + user_id: "alice".to_string(), + claims: vec!["admin".to_string()], + tenant_id: Some("acme".to_string()), + }; + let command = super::detached_runner_command( + std::path::Path::new("/usr/local/bin/animus"), + "/tmp/project", + "wf-actor", + &super::DetachedRunnerOverrides { actor: Some(actor.clone()), ..Default::default() }, + ); + let (_, value) = command + .get_envs() + .find(|(k, _)| *k == std::ffi::OsStr::new(super::WORKFLOW_ACTOR_ENV)) + .expect("actor env must be set when an actor is present"); + let json = value.expect("actor env must have a value").to_string_lossy().into_owned(); + let decoded: animus_actor::Actor = serde_json::from_str(&json).expect("actor env must be valid JSON"); + assert_eq!(decoded, actor, "actor must round-trip through the env handoff unchanged"); } #[cfg(unix)] diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs index 9a6bf670..9249a471 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs @@ -193,7 +193,7 @@ async fn render_ad_hoc_prompts( fn effective_existing_workflow_ref(project_root: &str, workflow: &OrchestratorWorkflow) -> Result { ensure_workflow_config_compiled(Path::new(project_root))?; - let workflow_config = load_workflow_config(Path::new(project_root))?; + let workflow_config = load_workflow_config(Path::new(project_root), None)?; Ok(workflow.workflow_ref.clone().unwrap_or_else(|| workflow_config.default_workflow_ref.clone())) } diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_agent/provider_client.rs b/crates/orchestrator-cli/src/services/runtime/runtime_agent/provider_client.rs index 8dafcba1..d170392e 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_agent/provider_client.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_agent/provider_client.rs @@ -324,6 +324,10 @@ pub(crate) fn session_request_from_args(args: &AgentRunArgs, project_root: &str) env_vars, mcp_servers: extras.get("mcp_servers").cloned(), extras: Value::Object(extras), + // CLI-local `animus agent run`: no authenticated control identity, so no + // actor. The actor reaches a provider session only when relayed from the + // workflow runner; it is never synthesized from local CLI context. + actor: None, }) } @@ -767,6 +771,7 @@ mod tests { timeout_secs: None, env_vars: Vec::new(), extras: serde_json::json!({}), + actor: None, }; let err = start_session(tmp.path(), request).await.expect_err("missing provider must error"); let message = err.to_string(); 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 0ffa7458..caa641a0 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_chat/turn.rs @@ -125,6 +125,7 @@ impl ResolverTurnProducer { env_vars: Vec::new(), mcp_servers: None, extras: Value::Object(Default::default()), + actor: None, }; self.resolver.resolve(&probe).ok() } @@ -487,6 +488,8 @@ async fn drive_once( env_vars, mcp_servers: extras.get("mcp_servers").cloned(), extras, + // CLI-local chat turn: no authenticated control identity → no actor. + actor: None, }; let mut run = producer.start(request, resume_id.as_deref()).await?; diff --git a/crates/orchestrator-cli/tests/plugin_agent_run_e2e.rs b/crates/orchestrator-cli/tests/plugin_agent_run_e2e.rs index 9412b73d..d7de1719 100644 --- a/crates/orchestrator-cli/tests/plugin_agent_run_e2e.rs +++ b/crates/orchestrator-cli/tests/plugin_agent_run_e2e.rs @@ -63,6 +63,7 @@ fn build_request() -> SessionRequest { timeout_secs: Some(15), env_vars: Vec::new(), extras: json!({}), + actor: None, } } @@ -208,6 +209,7 @@ async fn agent_run_errors_when_provider_plugin_missing() { timeout_secs: Some(5), env_vars: Vec::new(), extras: json!({}), + actor: None, }; let err = match resolver.resolve(&request) { diff --git a/crates/orchestrator-config/Cargo.toml b/crates/orchestrator-config/Cargo.toml index 478dab61..1b6dfa9c 100644 --- a/crates/orchestrator-config/Cargo.toml +++ b/crates/orchestrator-config/Cargo.toml @@ -11,6 +11,9 @@ protocol = { workspace = true } # v0.6: resolve an installed `config_source` plugin (else fall back to in-tree YAML). orchestrator-plugin-host = { workspace = true } animus-config-protocol = { workspace = true } +# v0.6.20: thread the transport-asserted per-user Actor through the config-load +# path (and re-key the compiled-config cache by actor identity). +animus-actor = { workspace = true } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/orchestrator-config/src/agent_runtime_config.rs b/crates/orchestrator-config/src/agent_runtime_config.rs index a24feb2e..c9281952 100644 --- a/crates/orchestrator-config/src/agent_runtime_config.rs +++ b/crates/orchestrator-config/src/agent_runtime_config.rs @@ -504,7 +504,7 @@ pub fn load_agent_runtime_config(project_root: &Path) -> Result Result { - if let Ok(loaded_workflow) = crate::workflow_config::load_workflow_config_with_metadata(project_root) { + if let Ok(loaded_workflow) = crate::workflow_config::load_workflow_config_with_metadata(project_root, None) { let mut config = builtin_agent_runtime_config(); let registry = crate::resolve_pack_registry(project_root)?; let mut path = loaded_workflow.path.clone(); @@ -2545,7 +2545,8 @@ cli_tools: write_minimal_project_workflow(temp.path()); let _base = install_yaml_config_source_base(temp.path()); - crate::workflow_config::load_workflow_config_with_metadata(temp.path()).expect("workflow config should load"); + crate::workflow_config::load_workflow_config_with_metadata(temp.path(), None) + .expect("workflow config should load"); let resolved = load_agent_runtime_config_with_metadata(temp.path()).expect("load runtime config"); let command = resolved.config.phase_command("pack-review").expect("pack review command"); let tool = resolved.config.cli_tools.get("pack-tool").expect("pack tool"); diff --git a/crates/orchestrator-config/src/pack_registry.rs b/crates/orchestrator-config/src/pack_registry.rs index eb8ad373..376508b9 100644 --- a/crates/orchestrator-config/src/pack_registry.rs +++ b/crates/orchestrator-config/src/pack_registry.rs @@ -1163,7 +1163,8 @@ workflows: .expect("write project workflows"); let _base = install_yaml_config_source_base(project.path()); - let loaded = crate::load_workflow_config_with_metadata(project.path()).expect("load effective workflow config"); + let loaded = + crate::load_workflow_config_with_metadata(project.path(), None).expect("load effective workflow config"); let workflows = loaded.config.workflows.iter().map(|workflow| workflow.id.as_str()).collect::>(); let standard = loaded .config @@ -1210,7 +1211,8 @@ workflows: .expect("write project workflows"); let _base = install_yaml_config_source_base(project.path()); - let loaded = crate::load_workflow_config_with_metadata(project.path()).expect("load effective workflow config"); + let loaded = + crate::load_workflow_config_with_metadata(project.path(), None).expect("load effective workflow config"); let standard = loaded .config .workflows @@ -1334,8 +1336,8 @@ version = ">=1.0.0" .expect("write workflow overlay"); let _base = install_yaml_config_source_base(project.path()); - let error = - crate::load_workflow_config_with_metadata(project.path()).expect_err("missing dependency should fail"); + let error = crate::load_workflow_config_with_metadata(project.path(), None) + .expect_err("missing dependency should fail"); assert!(error.to_string().contains("requires dependency 'animus.missing'")); } @@ -1389,7 +1391,7 @@ tools = ["cargo"] .expect("write workflow overlay"); let _base = install_yaml_config_source_base(project.path()); - let error = crate::load_workflow_config_with_metadata(project.path()) + let error = crate::load_workflow_config_with_metadata(project.path(), None) .expect_err("undeclared tool permission should fail"); assert!(error.to_string().contains("without declaring it in permissions.tools")); } @@ -1461,7 +1463,7 @@ required_env = ["PACK_SECRET_TOKEN"] // skips the scaffold and the merged config validates without those phases. write_minimal_project_workflow(project.path()); let _base = install_yaml_config_source_base(project.path()); - crate::load_workflow_config_with_metadata(project.path()) + crate::load_workflow_config_with_metadata(project.path(), None) .expect("workflow config loading should not require secrets"); } @@ -1531,7 +1533,7 @@ workflows: // workflow, so the merged config validates without those phases. write_minimal_project_workflow(project.path()); let _base = install_yaml_config_source_base(project.path()); - crate::load_workflow_config_with_metadata(project.path()) + crate::load_workflow_config_with_metadata(project.path(), None) .expect("workflow config loading should not probe runtimes"); } diff --git a/crates/orchestrator-config/src/workflow_config/config_source_client.rs b/crates/orchestrator-config/src/workflow_config/config_source_client.rs index ca5f84bf..56e12c10 100644 --- a/crates/orchestrator-config/src/workflow_config/config_source_client.rs +++ b/crates/orchestrator-config/src/workflow_config/config_source_client.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; +use animus_actor::Actor; use anyhow::{anyhow, Context, Result}; use orchestrator_plugin_host::session::plugin_supervisor::{classify, RetryDecision}; use orchestrator_plugin_host::{discover_by_kind, DiscoveredPlugin, HostError, PluginHost, PluginSpawnOptions}; @@ -73,22 +74,71 @@ fn resident_hosts() -> &'static Mutex> { HOSTS.get_or_init(|| Mutex::new(HashMap::new())) } -/// Last compiled config served for a root, keyed by the plugin's CacheToken -/// version. A `config/load` whose token matches `version` can skip the whole -/// pack-overlay merge + validate compile and reuse `compiled`. Invalidated on -/// `config/write` and on host re-spawn so a real config change always +/// Compiled-config cache map: `(normalized project root, actor partition)` => +/// `(CacheToken version, compiled config)`. The actor partition (see +/// [`actor_cache_key`]) keeps one user's compiled config from being served to +/// another. +type CompiledCacheMap = HashMap<(PathBuf, String), (String, LoadedWorkflowConfig)>; + +/// Last compiled config served for a `(root, actor)` pair, keyed by the plugin's +/// CacheToken version. A `config/load` whose token matches `version` can skip the +/// whole pack-overlay merge + validate compile and reuse `compiled`. Invalidated +/// on `config/write` and on host re-spawn so a real config change always /// recompiles. Lives here (not in `loading.rs`) so write + load share one /// invalidation point. -fn compiled_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); +/// +/// SECURITY: the key includes an [`actor_cache_key`] partition so one user's +/// compiled config can never be served to another. A `config_source` plugin may +/// return per-user config (the actor reaches it via the `config/load` params), +/// so a root-only key would leak user A's private config to user B on a cache +/// hit. `None` (no authenticated actor) maps to the shared `__global__` +/// partition. +fn compiled_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } -/// Return the cached compiled config for `project_root` iff its stored -/// CacheToken `version` matches `cache_version`. The kernel's `loading.rs` -/// calls this to short-circuit recompilation when the source is unchanged. -pub(crate) fn cached_compiled(project_root: &Path, cache_version: &str) -> Option { - let key = normalize_root(project_root); +/// Stable cache-partition key derived from the actor identity. Two actors with +/// the same `user_id`, claim set, and tenant collide (correct — they see the +/// same scoped config); any difference partitions them. Claims are sorted so the +/// key is order-independent. `None` => the shared `__global__` partition. +/// +/// SECURITY: the encoding MUST be unambiguous — a delimiter-joined string (e.g. +/// `user_id|claims|tenant`) lets distinct identities collide when a field +/// contains the delimiter (`user_id="a|b"`+claim `c` vs `user_id="a"`+claim +/// `b|c`), which would serve one actor another's cached config. JSON of the +/// sorted fields is self-delimiting (strings are escaped), so no field value can +/// forge another identity's key. `__global__` cannot collide with a JSON object +/// (which always starts with `{`). +pub(crate) fn actor_cache_key(actor: Option<&Actor>) -> String { + match actor { + None => "__global__".to_string(), + Some(actor) => { + let mut claims = actor.claims.clone(); + claims.sort(); + // serde_json::to_string on owned strings/Vec/Option + // is infallible in practice; fall back to a Debug encoding (still + // unambiguous) on the impossible error rather than panicking. + serde_json::to_string(&serde_json::json!({ + "u": actor.user_id, + "c": claims, + "t": actor.tenant_id, + })) + .unwrap_or_else(|_| format!("{:?}", (&actor.user_id, &claims, &actor.tenant_id))) + } + } +} + +/// Return the cached compiled config for `(project_root, actor)` iff its stored +/// CacheToken `version` matches `cache_version`. The kernel's `loading.rs` calls +/// this to short-circuit recompilation when the source is unchanged for THIS +/// actor — a different actor never hits another actor's entry. +pub(crate) fn cached_compiled( + project_root: &Path, + actor: Option<&Actor>, + cache_version: &str, +) -> Option { + let key = (normalize_root(project_root), actor_cache_key(actor)); let guard = compiled_cache().lock().unwrap_or_else(|p| p.into_inner()); match guard.get(&key) { Some((version, loaded)) if version == cache_version => Some(loaded.clone()), @@ -96,17 +146,26 @@ pub(crate) fn cached_compiled(project_root: &Path, cache_version: &str) -> Optio } } -/// Store the compiled config for `project_root` under its CacheToken `version`. -pub(crate) fn store_compiled(project_root: &Path, cache_version: String, loaded: LoadedWorkflowConfig) { - let key = normalize_root(project_root); +/// Store the compiled config for `(project_root, actor)` under its CacheToken +/// `version`. +pub(crate) fn store_compiled( + project_root: &Path, + actor: Option<&Actor>, + cache_version: String, + loaded: LoadedWorkflowConfig, +) { + let key = (normalize_root(project_root), actor_cache_key(actor)); compiled_cache().lock().unwrap_or_else(|p| p.into_inner()).insert(key, (cache_version, loaded)); } /// Drop the cached compiled config for `project_root` so the next load -/// recompiles. Called after a `config/write` (the source changed under us). +/// recompiles. Called after a `config/write` (the source changed under us) and +/// on host re-spawn. Clears EVERY actor partition for the root: a write (or a +/// source/host change) can alter what any user sees, so a single global event +/// must invalidate all per-actor entries, not just the writer's. fn invalidate_compiled(project_root: &Path) { - let key = normalize_root(project_root); - compiled_cache().lock().unwrap_or_else(|p| p.into_inner()).remove(&key); + let root = normalize_root(project_root); + compiled_cache().lock().unwrap_or_else(|p| p.into_inner()).retain(|(cached_root, _actor), _| cached_root != &root); } /// Normalize a project root the same way the test seam does, so the resident @@ -160,7 +219,7 @@ pub fn config_source_installed(project_root: &Path) -> bool { /// `Ok(None)` => no plugin installed and (in tests) no injected base; the /// caller surfaces an actionable "no config_source plugin installed" error. /// Returns `(base WorkflowConfig, cache_token_version)`. -pub fn resolve_plugin_base(project_root: &Path) -> Result> { +pub fn resolve_plugin_base(project_root: &Path, actor: Option<&Actor>) -> Result> { // Test-only seam: a synthetic base config injected via // `set_test_plugin_base` stands in for an installed config_source plugin so // unit tests can exercise the kernel's pack-merge + validate pipeline (which @@ -180,7 +239,7 @@ pub fn resolve_plugin_base(project_root: &Path) -> Result Result Result<(WorkflowConfig, String)> { +async fn load_via_resident( + plugin: DiscoveredPlugin, + project_root: PathBuf, + actor: Option, +) -> Result<(WorkflowConfig, String)> { let name = plugin.name.clone(); let call_root = project_root.clone(); with_resident_host(plugin, &project_root, move |host| { let project_root = call_root.clone(); let name = name.clone(); - async move { config_load_call(&host, &name, &project_root).await } + let actor = actor.clone(); + async move { config_load_call(&host, &name, &project_root, actor.as_ref()).await } }) .await } @@ -449,13 +513,22 @@ async fn config_load_call( host: &PluginHost, plugin_name: &str, project_root: &Path, + actor: Option<&Actor>, ) -> std::result::Result<(WorkflowConfig, String), ResidentCallError> { // Compute the repo-scope id from the project root so config_source plugins // that select config rows / repo-scoped secrets by scope (e.g. postgres) // get a real scope, not null. + // + // TRUST BOUNDARY: `actor` is the transport-asserted caller identity relayed + // verbatim from the authenticated control request. It is serialized into the + // `config/load` params (`null` when absent) so a per-user `config_source` + // plugin can scope its result. The kernel never authenticates or interprets + // it — and it is NEVER sourced from workflow YAML, agent output, or subject + // content. let params = serde_json::json!({ "project_root": project_root, "repo_scope": protocol::repository_scope_for_path(project_root), + "actor": actor, }); let value = host .request_typed_with_timeout("config/load", Some(params), CONFIG_LOAD_TIMEOUT) @@ -593,8 +666,16 @@ mod resident_cache_tests { use animus_config_protocol::builtins::builtin_workflow_config; fn loaded(root: &Path) -> LoadedWorkflowConfig { + loaded_marked(root, "") + } + + /// Build a `LoadedWorkflowConfig` tagged with `marker` in `default_workflow_ref` + /// so a test can prove two cached entries are distinct (no cross-actor leak). + fn loaded_marked(root: &Path, marker: &str) -> LoadedWorkflowConfig { + let mut config = builtin_workflow_config(); + config.default_workflow_ref = marker.to_string(); LoadedWorkflowConfig { - config: builtin_workflow_config(), + config, metadata: super::super::types::WorkflowConfigMetadata { schema: String::new(), version: 0, @@ -605,28 +686,106 @@ mod resident_cache_tests { } } + fn actor(user_id: &str) -> Actor { + Actor { user_id: user_id.to_string(), claims: Vec::new(), tenant_id: None } + } + #[test] fn compiled_cache_hits_on_matching_token_and_misses_otherwise() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); // Cold: nothing cached. - assert!(cached_compiled(root, "tok-1").is_none()); + assert!(cached_compiled(root, None, "tok-1").is_none()); - store_compiled(root, "tok-1".to_string(), loaded(root)); + store_compiled(root, None, "tok-1".to_string(), loaded(root)); // Same token: hit. - assert!(cached_compiled(root, "tok-1").is_some()); + assert!(cached_compiled(root, None, "tok-1").is_some()); // Different token (source changed): miss => caller recompiles. - assert!(cached_compiled(root, "tok-2").is_none()); + assert!(cached_compiled(root, None, "tok-2").is_none()); } #[test] fn invalidate_compiled_forces_recompile() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); - store_compiled(root, "tok".to_string(), loaded(root)); - assert!(cached_compiled(root, "tok").is_some()); + store_compiled(root, None, "tok".to_string(), loaded(root)); + assert!(cached_compiled(root, None, "tok").is_some()); invalidate_compiled(root); - assert!(cached_compiled(root, "tok").is_none(), "write must invalidate the compiled cache"); + assert!(cached_compiled(root, None, "tok").is_none(), "write must invalidate the compiled cache"); + } + + #[test] + fn compiled_cache_never_leaks_across_actors() { + // (a) SAME project_root + token, two DIFFERENT actors => two distinct + // compiled configs. Actor B must never receive actor A's entry. + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let alice = actor("alice"); + let bob = actor("bob"); + + store_compiled(root, Some(&alice), "tok".to_string(), loaded_marked(root, "alice-wf")); + store_compiled(root, Some(&bob), "tok".to_string(), loaded_marked(root, "bob-wf")); + + let a = cached_compiled(root, Some(&alice), "tok").expect("alice cached"); + let b = cached_compiled(root, Some(&bob), "tok").expect("bob cached"); + assert_eq!(a.config.default_workflow_ref, "alice-wf"); + assert_eq!(b.config.default_workflow_ref, "bob-wf", "bob must not be served alice's compiled config"); + + // The global (actor=None) partition is independent of both users. + assert!( + cached_compiled(root, None, "tok").is_none(), + "per-actor stores must not populate the global partition" + ); + } + + #[test] + fn actor_cache_key_is_claim_order_independent_and_partitions_identity() { + // (b) actor=None maps to the shared global partition, unchanged from + // today's behavior. + assert_eq!(actor_cache_key(None), "__global__"); + + // Claim order does not matter (sorted), so the same identity always + // shares one partition. + let unsorted = Actor { user_id: "u".into(), claims: vec!["b".into(), "a".into()], tenant_id: Some("t".into()) }; + let sorted = Actor { user_id: "u".into(), claims: vec!["a".into(), "b".into()], tenant_id: Some("t".into()) }; + assert_eq!(actor_cache_key(Some(&unsorted)), actor_cache_key(Some(&sorted))); + + // Different user / tenant / claims partition. + assert_ne!(actor_cache_key(Some(&actor("alice"))), actor_cache_key(Some(&actor("bob")))); + let no_tenant = Actor { user_id: "u".into(), claims: vec!["a".into()], tenant_id: None }; + assert_ne!(actor_cache_key(Some(&sorted)), actor_cache_key(Some(&no_tenant))); + + // SECURITY: delimiter-bearing fields must NOT collide. With a naive + // `user_id|claims.join(",")|tenant` encoding, (`a|b`, [`c`]) and + // (`a`, [`b|c`]) would both render `a|b|c|` and share a cache partition. + // The unambiguous (JSON) encoding must keep them distinct. + let pipe_user = Actor { user_id: "a|b".into(), claims: vec!["c".into()], tenant_id: None }; + let pipe_claim = Actor { user_id: "a".into(), claims: vec!["b|c".into()], tenant_id: None }; + assert_ne!( + actor_cache_key(Some(&pipe_user)), + actor_cache_key(Some(&pipe_claim)), + "delimiter-bearing identities must not collide into one cache partition" + ); + } + + #[test] + fn invalidate_clears_every_actor_partition_for_the_root() { + // (c) a global/source change (config/write) must invalidate EVERY actor's + // entry for the root, not just one partition. + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let alice = actor("alice"); + let bob = actor("bob"); + + store_compiled(root, Some(&alice), "tok".to_string(), loaded_marked(root, "alice-wf")); + store_compiled(root, Some(&bob), "tok".to_string(), loaded_marked(root, "bob-wf")); + store_compiled(root, None, "tok".to_string(), loaded(root)); + + invalidate_compiled(root); + + assert!(cached_compiled(root, Some(&alice), "tok").is_none(), "alice entry must be invalidated"); + assert!(cached_compiled(root, Some(&bob), "tok").is_none(), "bob entry must be invalidated"); + assert!(cached_compiled(root, None, "tok").is_none(), "global entry must be invalidated"); } #[test] diff --git a/crates/orchestrator-config/src/workflow_config/config_write.rs b/crates/orchestrator-config/src/workflow_config/config_write.rs index ca880400..a0563769 100644 --- a/crates/orchestrator-config/src/workflow_config/config_write.rs +++ b/crates/orchestrator-config/src/workflow_config/config_write.rs @@ -53,7 +53,10 @@ fn read_modify_write(project_root: &Path, mutate: F) -> Result Result<()>, { - let (mut config, _cache_token) = resolve_plugin_base(project_root) + // Config authoring (WU-F) is out of scope for the actor wave: this in-tree + // write path runs without an authenticated actor, so it reads the global + // (`actor = None`) base. A future wave threads the writer's actor here. + let (mut config, _cache_token) = resolve_plugin_base(project_root, None) .context("loading the raw config from the config_source plugin before mutating it")? .ok_or_else(|| { anyhow!( diff --git a/crates/orchestrator-config/src/workflow_config/loading.rs b/crates/orchestrator-config/src/workflow_config/loading.rs index 517e1adc..21adb4fb 100644 --- a/crates/orchestrator-config/src/workflow_config/loading.rs +++ b/crates/orchestrator-config/src/workflow_config/loading.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use animus_actor::Actor; use anyhow::{anyhow, Result}; use sha2::{Digest, Sha256}; @@ -38,14 +39,14 @@ pub fn ensure_workflow_config_compiled(project_root: &Path) -> Result<()> { return Ok(()); } - load_workflow_config_with_metadata(project_root).map(|_| ()) + load_workflow_config_with_metadata(project_root, None).map(|_| ()) } -pub fn load_workflow_config(project_root: &Path) -> Result { - Ok(load_workflow_config_with_metadata(project_root)?.config) +pub fn load_workflow_config(project_root: &Path, actor: Option<&Actor>) -> Result { + Ok(load_workflow_config_with_metadata(project_root, actor)?.config) } -pub fn load_workflow_config_with_metadata(project_root: &Path) -> Result { +pub fn load_workflow_config_with_metadata(project_root: &Path, actor: Option<&Actor>) -> Result { // v0.6: the project's base `WorkflowConfig` is sourced EXCLUSIVELY by an // installed `config_source` plugin. The kernel no longer parses // `.animus/*.yaml` in its runtime load path — the YAML parser lives on as a @@ -53,7 +54,7 @@ pub fn load_workflow_config_with_metadata(project_root: &Path) -> Result Result Result LoadedWorkflowConfig { - match load_workflow_config_with_metadata(project_root) { + // The default/fallback path is system-initiated (daemon reconcilers, + // schedulers, CLI inspection) with no authenticated actor → the global + // (`actor = None`) config partition. + match load_workflow_config_with_metadata(project_root, None) { Ok(loaded) => loaded, Err(_) => { let config = runtime_workflow_config_base(); diff --git a/crates/orchestrator-config/src/workflow_config/tests.rs b/crates/orchestrator-config/src/workflow_config/tests.rs index dd686eaf..bb859cb9 100644 --- a/crates/orchestrator-config/src/workflow_config/tests.rs +++ b/crates/orchestrator-config/src/workflow_config/tests.rs @@ -101,7 +101,7 @@ fn missing_v2_file_reports_actionable_error() { let _lock = env_lock().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let temp = tempfile::tempdir().expect("tempdir"); let _home_guard = crate::test_support::EnvVarGuard::set("HOME", temp.path()); - let error = load_workflow_config(temp.path()).expect_err("missing workflow config should fail"); + let error = load_workflow_config(temp.path(), None).expect_err("missing workflow config should fail"); // v0.6: with no config_source plugin installed (the unit-test case), the // kernel load path has no base to source and errors actionably. assert!(error.to_string().contains("no config_source plugin installed"), "got: {error}"); @@ -4063,12 +4063,12 @@ mod cache_token_short_circuit { // First base => first load compiles and caches under the content token. let base_a = base_with_marker("marker-a"); let guard_a = test_seam::install(root, base_a); - let loaded_a = load_workflow_config_with_metadata(root).expect("first load"); + let loaded_a = load_workflow_config_with_metadata(root, None).expect("first load"); assert!(loaded_a.config.tools_allowlist.contains(&"marker-a".to_string())); // Same base again: the cached compile is served (token unchanged). The // result is identical, proving the short-circuit returns the right value. - let loaded_a2 = load_workflow_config_with_metadata(root).expect("second load"); + let loaded_a2 = load_workflow_config_with_metadata(root, None).expect("second load"); assert_eq!(loaded_a2.metadata.hash, loaded_a.metadata.hash, "unchanged source must serve the cached compile"); drop(guard_a); @@ -4076,7 +4076,7 @@ mod cache_token_short_circuit { // recompile, never serving the stale compile. let base_b = base_with_marker("marker-b"); let _guard_b = test_seam::install(root, base_b); - let loaded_b = load_workflow_config_with_metadata(root).expect("third load after change"); + let loaded_b = load_workflow_config_with_metadata(root, None).expect("third load after change"); assert!( loaded_b.config.tools_allowlist.contains(&"marker-b".to_string()), "a changed source token must recompile, not serve the stale cached config" diff --git a/crates/orchestrator-core/src/services/tests.rs b/crates/orchestrator-core/src/services/tests.rs index d5e856a8..f6a05179 100644 --- a/crates/orchestrator-core/src/services/tests.rs +++ b/crates/orchestrator-core/src/services/tests.rs @@ -104,7 +104,7 @@ workflows: let _reloaded = file_hub(temp.path()).expect("reload hub"); let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let config = crate::load_workflow_config(temp.path()).expect("workflow config should load"); + let config = crate::load_workflow_config(temp.path(), None).expect("workflow config should load"); assert_eq!(config.default_workflow_ref.as_str(), "yaml-standard"); assert!( @@ -205,7 +205,7 @@ fn file_hub_bootstraps_workflow_yaml_with_phase_catalog() { let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(&project_path); - let config = crate::load_workflow_config(&project_path).expect("workflow config should load"); + let config = crate::load_workflow_config(&project_path, None).expect("workflow config should load"); assert_eq!(config.schema.as_str(), "animus.workflow-config.v2"); assert_eq!(config.version, 2); @@ -660,7 +660,7 @@ workflows: let scoped = scoped_ao_root(temp.path()); assert!(!scoped.join("state").join("workflow-config.v2.json").exists(), "no JSON config should exist"); - let config = crate::load_workflow_config(temp.path()).expect("yaml-only repo should load workflow config"); + let config = crate::load_workflow_config(temp.path(), None).expect("yaml-only repo should load workflow config"); assert!( config.workflows.iter().any(|w| w.id == "yaml-only-workflow"), "workflow config should include yaml-defined workflow" @@ -830,7 +830,7 @@ async fn file_hub_auto_prunes_checkpoints_on_completion_when_enabled() { let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let mut config = crate::load_workflow_config(temp.path()).expect("load workflow config"); + let mut config = crate::load_workflow_config(temp.path(), None).expect("load workflow config"); config.checkpoint_retention.keep_last_per_phase = 1; config.checkpoint_retention.max_age_hours = None; config.checkpoint_retention.auto_prune_on_completion = true; @@ -862,7 +862,7 @@ async fn file_hub_completion_remains_successful_when_auto_prune_errors() { let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let mut config = crate::load_workflow_config(temp.path()).expect("load workflow config"); + let mut config = crate::load_workflow_config(temp.path(), None).expect("load workflow config"); config.checkpoint_retention.keep_last_per_phase = 1; config.checkpoint_retention.max_age_hours = Some(u64::MAX); config.checkpoint_retention.auto_prune_on_completion = true; @@ -1098,7 +1098,7 @@ async fn planning_execute_starts_workflows_with_config_phase_plan() { let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let mut workflow_config = crate::load_workflow_config(temp.path()).expect("load config"); + let mut workflow_config = crate::load_workflow_config(temp.path(), None).expect("load config"); workflow_config.workflows.push(crate::WorkflowDefinition { id: "planning-custom".to_string(), name: "Planning Custom".to_string(), diff --git a/crates/orchestrator-core/src/workflow/phase_plan.rs b/crates/orchestrator-core/src/workflow/phase_plan.rs index 7724a3fc..fd3d01e9 100644 --- a/crates/orchestrator-core/src/workflow/phase_plan.rs +++ b/crates/orchestrator-core/src/workflow/phase_plan.rs @@ -97,7 +97,7 @@ pub fn resolve_phase_plan_for_workflow_ref( )); } - let loaded_workflow = crate::load_workflow_config_with_metadata(root)?; + let loaded_workflow = crate::load_workflow_config_with_metadata(root, None)?; let workflow_config = loaded_workflow.config; let runtime_config = crate::load_agent_runtime_config_or_default(root); crate::validate_workflow_and_runtime_configs_with_project_root(&workflow_config, &runtime_config, Some(root))?; diff --git a/crates/orchestrator-daemon-runtime/Cargo.toml b/crates/orchestrator-daemon-runtime/Cargo.toml index 75cad877..212b3d92 100644 --- a/crates/orchestrator-daemon-runtime/Cargo.toml +++ b/crates/orchestrator-daemon-runtime/Cargo.toml @@ -24,19 +24,15 @@ orchestrator-core = { workspace = true } orchestrator-logging = { workspace = true } orchestrator-plugin-host = { workspace = true } animus-plugin-protocol = { workspace = true } -animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -animus-log-storage-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -# We use Subject / SubjectChangedEvent from the same v0.1.13 surface the -# control-protocol references. Pulled from the same git tag so the -# protocol versions stay in lockstep; subject backends / inproc adapters -# pin the matching upstream version, and the daemon translates at the -# boundary. -animus-subject-protocol-wire = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13" } -# v0.5 plugin protocol crates used by `plugin_clients` for routing -# `queue/*` and `workflow/*` RPCs through installed plugins. -# `protocol::SubjectDispatch` (re-exported from `animus-subject-protocol`) -# remains the canonical type; `services::plugin_clients` serializes via -# the v0.5 types over the wire. +# 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 +# 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-actor = { workspace = true } +# Subject / SubjectChangedEvent share the same v0.1.25 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" } protocol = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -55,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.13", features = ["client"] } +animus-control-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25", 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.13" } +animus-subject-protocol-wire = { package = "animus-subject-protocol", git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.25" } animus-runtime-shared = { workspace = true } [lints] diff --git a/crates/orchestrator-plugin-host/src/session/plugin_backend.rs b/crates/orchestrator-plugin-host/src/session/plugin_backend.rs index 4f86fdc7..5c77357b 100644 --- a/crates/orchestrator-plugin-host/src/session/plugin_backend.rs +++ b/crates/orchestrator-plugin-host/src/session/plugin_backend.rs @@ -946,6 +946,10 @@ fn session_request_from_agent_run_request( env_vars: Vec::new(), mcp_servers: context.get("mcp_servers").cloned(), extras: Value::Object(extras), + // The resume path carries no authenticated control identity. The actor + // reaches a session only when relayed from the workflow runner's + // WorkflowExecuteRequest; it is never synthesized from a checkpoint. + actor: None, }) } @@ -1473,6 +1477,7 @@ mod tests { timeout_secs: None, env_vars: Vec::new(), extras: json!({ "reasoning_effort": "high" }), + actor: None, }; let params = backend.build_run_params(&request, None, "ctrl-test"); assert_eq!( @@ -1509,6 +1514,7 @@ mod tests { timeout_secs: None, env_vars: Vec::new(), extras: json!({ "approvals": true }), + actor: None, }; let params = backend.build_run_params(&request, None, "ctrl-test"); assert_eq!( @@ -1543,6 +1549,7 @@ mod tests { timeout_secs: None, env_vars: Vec::new(), extras: json!({}), + actor: None, }; let control_session_id = Uuid::new_v4().to_string(); let params = backend.build_run_params(&request, None, &control_session_id); @@ -1839,6 +1846,7 @@ mod tests { timeout_secs: None, env_vars: filtered, extras: Value::Object(Default::default()), + actor: None, }; let params = backend.build_run_params(&req, None, "ctrl-test"); let env_obj = params.get("env").and_then(Value::as_object).expect("env param must be an object"); diff --git a/crates/orchestrator-plugin-host/src/session/session_backend_resolver.rs b/crates/orchestrator-plugin-host/src/session/session_backend_resolver.rs index b5a7528e..88ec14d0 100644 --- a/crates/orchestrator-plugin-host/src/session/session_backend_resolver.rs +++ b/crates/orchestrator-plugin-host/src/session/session_backend_resolver.rs @@ -155,6 +155,7 @@ mod tests { timeout_secs: None, env_vars: Vec::new(), extras: json!({}), + actor: None, } } diff --git a/crates/orchestrator-plugin-host/tests/plugin_supervisor.rs b/crates/orchestrator-plugin-host/tests/plugin_supervisor.rs index f9d257ca..9d34bee8 100644 --- a/crates/orchestrator-plugin-host/tests/plugin_supervisor.rs +++ b/crates/orchestrator-plugin-host/tests/plugin_supervisor.rs @@ -109,6 +109,7 @@ fn make_request(cwd: PathBuf) -> SessionRequest { timeout_secs: Some(10), env_vars: vec![], extras: json!({}), + actor: None, } } From 8e2bcaeab103ad2ac54073b3f8e952cad1cec378 Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 11:51:27 -0600 Subject: [PATCH 2/3] feat(actor): scope workflow bootstrap + lifecycle to the actor; relay actor to the MCP sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK 1 (resolve the deferred bootstrap P2): thread the transport-asserted Actor through the workflow run/bootstrap + the phase-plan / config / retry resolution it calls, so a user's PRIVATE workflow (visible only in that actor's config_source partition) resolves at run-time, not from the global view. - `WorkflowServiceApi::run(input, actor: Option<&Actor>)`: actor-scoped default-workflow-ref / phase-plan / skip-guard / retry resolution. actor=None == today's global behavior. Both FileServiceHub + InMemory impls + all callers updated (system/local/test callers pass None; the control-routed detached start passes the run's actor via DetachedRunnerOverrides). - Added actor-aware variants (old fns delegate to None): `resolve_phase_plan_ for_workflow_ref_for_actor`, `load_workflow_config_or_default_for_actor`, `load_agent_runtime_config_*_for_actor`. - Persist the actor on the run so EVERY later lifecycle transition (resume / pause / cancel / complete / fail / mark_* / resolve_merge_conflict) re-resolves from the same partition — otherwise a private workflow starts then fails at the next transition. Stored in a kernel-owned `workflows.actor` SQLite column (NOT the protocol record); `save()` switched from INSERT OR REPLACE to an ON CONFLICT upsert so the column survives every later save. Test seam gains per-(root,actor) base injection. TASK 2 (WU-G): relay the actor to the per-agent `animus mcp serve` sidecar so an agent's MCP tool calls can scope to the user (per-user integrations / subject / queue). The built-in MCP server is spawned PER AGENT SESSION (runtime_contract mcp.stdio.command), so the actor reaches it per-agent-spawn: - `inject_default_stdio_mcp_for_agent(.., actor)` appends `--actor-json` to the default serve args (in-tree ad-hoc callers pass None; the out-of-tree runner passes WorkflowPhaseRunRequest.actor). - `animus mcp serve --actor-json` parses the Actor and pins it on AoMcpServer (`pinned_actor`), exposed via `pinned_actor()`; `run_tool` (the single tool choke point) emits per-user audit. Full per-tool ENFORCEMENT (scoping the underlying subject/queue/integration RPCs) is the consumer-side change tracked separately. TRUST BOUNDARY: the actor is sourced ONLY from the authenticated run/control request, never from workflow YAML, agent output, or subject content. Tests: actor-private workflow resolves for its actor and NOT for None/another actor; run() persists the actor and it survives a lifecycle save; --actor-json clap parse; inject appends/omits --actor-json; actor round-trips through the serve arg. --- Cargo.lock | 2 + crates/animus-runtime-shared/Cargo.toml | 1 + .../src/runtime_contract.rs | 101 ++++++++++++++- .../src/cli_types/mcp_types.rs | 10 ++ crates/orchestrator-cli/src/cli_types/mod.rs | 23 ++++ .../src/services/cost/enforcement.rs | 2 +- .../src/services/operations/ops_mcp.rs | 36 +++++- .../src/services/operations/ops_mcp/exec.rs | 19 +++ .../operations/ops_mcp/interaction_tools.rs | 17 +-- .../src/services/operations/ops_mcp/tests.rs | 2 +- .../ops_mcp/tool_discovery_tools.rs | 12 +- .../operations/ops_workflow/phases.rs | 14 ++- .../operations/ops_workflow/prompt.rs | 12 +- .../src/services/runtime/agent_mcp.rs | 4 + .../runtime/runtime_agent/interactions.rs | 2 +- .../runtime_daemon/daemon_reconciliation.rs | 4 +- .../runtime/runtime_daemon/daemon_run.rs | 7 +- .../runtime_daemon/daemon_tick_executor.rs | 6 +- .../src/agent_runtime_config.rs | 25 +++- .../workflow_config/config_source_client.rs | 51 +++++++- .../src/workflow_config/loading.rs | 10 +- .../src/workflow_config/mod.rs | 4 +- crates/orchestrator-core/Cargo.toml | 1 + crates/orchestrator-core/src/lib.rs | 34 +++--- crates/orchestrator-core/src/services.rs | 9 +- .../orchestrator-core/src/services/tests.rs | 115 ++++++++++++++---- .../src/services/workflow_impl.rs | 64 +++++++--- crates/orchestrator-core/src/workflow.rs | 3 +- .../src/workflow/phase_plan.rs | 73 ++++++++++- .../src/workflow/state_manager.rs | 58 ++++++++- .../orchestrator-core/src/workflow_events.rs | 6 +- 31 files changed, 616 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1d13710..52911f64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,6 +213,7 @@ dependencies = [ name = "animus-runtime-shared" version = "0.1.0" dependencies = [ + "animus-actor", "animus-plugin-protocol 0.1.0", "anyhow", "async-trait", @@ -2837,6 +2838,7 @@ dependencies = [ name = "orchestrator-core" version = "0.1.0" dependencies = [ + "animus-actor", "animus-plugin-protocol 0.1.0", "anyhow", "argon2", diff --git a/crates/animus-runtime-shared/Cargo.toml b/crates/animus-runtime-shared/Cargo.toml index cd09b2e0..e15efd5a 100644 --- a/crates/animus-runtime-shared/Cargo.toml +++ b/crates/animus-runtime-shared/Cargo.toml @@ -35,6 +35,7 @@ interprocess = "2.4" # agent-runner plugin can depend on a single canonical implementation. thiserror = "2" zstd = "0.13" +animus-actor = { workspace = true } animus-plugin-protocol = { workspace = true } orchestrator-plugin-host = { workspace = true } diff --git a/crates/animus-runtime-shared/src/runtime_contract.rs b/crates/animus-runtime-shared/src/runtime_contract.rs index f1273c7c..1f6c582c 100644 --- a/crates/animus-runtime-shared/src/runtime_contract.rs +++ b/crates/animus-runtime-shared/src/runtime_contract.rs @@ -1,5 +1,6 @@ use std::sync::{OnceLock, RwLock}; +use animus_actor::Actor; use anyhow::{anyhow, Result}; use serde_json::Value; use tracing::warn; @@ -331,7 +332,7 @@ pub fn inject_default_stdio_mcp_with_config( project_root: &str, mcp_config: &protocol::McpRuntimeConfig, ) { - inject_default_stdio_mcp_for_agent(runtime_contract, project_root, mcp_config, None); + inject_default_stdio_mcp_for_agent(runtime_contract, project_root, mcp_config, None, None); } /// Variant of [`inject_default_stdio_mcp_with_config`] that pins the spawned @@ -341,11 +342,23 @@ pub fn inject_default_stdio_mcp_with_config( /// cannot claim a sibling profile whose `approval_policy` is more permissive. /// The flag is only appended to the DEFAULT serve args — host-supplied /// `stdio_args_json` is passed through untouched. +/// +/// `actor` is the transport-asserted caller identity for this run (relayed from +/// the workflow runner's `WorkflowPhaseRunRequest.actor`). When present it is +/// serialized and appended as `--actor-json ` to the DEFAULT serve args, +/// so the per-agent `animus mcp serve` child is bound to the user and its tools +/// can scope per-user integrations / subject / queue ops. +/// +/// TRUST BOUNDARY: `actor` MUST originate only from the authenticated run (the +/// runner relays it from the daemon's authenticated control request). It is +/// NEVER derived from agent output, workflow YAML, or subject content. `None` = +/// system / local / unauthenticated run (global scope). pub fn inject_default_stdio_mcp_for_agent( runtime_contract: &mut Value, project_root: &str, mcp_config: &protocol::McpRuntimeConfig, agent_profile_id: Option<&str>, + actor: Option<&Actor>, ) { if runtime_contract.pointer("/mcp/stdio/command").and_then(Value::as_str).is_some_and(|v| !v.trim().is_empty()) { return; @@ -391,6 +404,31 @@ pub fn inject_default_stdio_mcp_for_agent( args.push("--agent-id".to_string()); args.push(agent_id.to_string()); } + // Bind the per-agent `animus mcp serve` child to the run's actor so + // its tools can scope per-user integrations / subject / queue ops. + // Encoded as JSON (self-delimiting); a serialization failure simply + // omits the flag (global scope) rather than aborting the run. + // + // TODO(codex-p2): the actor JSON (incl. tenant + advisory claims like + // `admin`) rides in argv, so it is readable via `ps` / process-listing + // on a shared multi-user host. This matches the existing `--agent-id` + // / `--workflow-id` argv precedent, but a less-exposed channel (an + // inherited env var or an opaque file handle the child reads) is + // preferable. The robust fix needs the provider plugin to propagate a + // dedicated env to the spawned stdio MCP child — the plugin host + // spawns with a FILTERED env (manifest `env_required`), so plain + // inheritance is unreliable; deferred as a cross-plugin change. + if let Some(actor) = actor { + match serde_json::to_string(actor) { + Ok(json) => { + args.push("--actor-json".to_string()); + args.push(json); + } + Err(error) => { + warn!(%error, "failed to encode actor for animus mcp serve; tools run with no actor"); + } + } + } args }); @@ -2095,6 +2133,67 @@ mod tests { ); } + fn default_serve_args(runtime_contract: &Value) -> Vec { + runtime_contract + .pointer("/mcp/stdio/args") + .and_then(Value::as_array) + .expect("stdio args") + .iter() + .map(|v| v.as_str().expect("arg is string").to_string()) + .collect() + } + + /// WU-G: the per-agent `animus mcp serve` child must be bound to the run's + /// actor via `--actor-json ` so its tools can scope per-user ops. The + /// actor round-trips losslessly through the serialized arg. + #[test] + fn inject_default_stdio_mcp_appends_actor_json_when_actor_present() { + let actor = Actor { + user_id: "alice".to_string(), + claims: vec!["admin".to_string()], + tenant_id: Some("acme".to_string()), + }; + let mut runtime_contract = serde_json::json!({ + "cli": { "capabilities": { "supports_mcp": true } }, + "mcp": {} + }); + // stdio_command set (so the command resolves) but NO stdio_args_json, so + // the DEFAULT serve-args branch (where --actor-json is appended) runs. + let mcp_config = protocol::McpRuntimeConfig { + stdio_command: Some("/usr/local/bin/animus".to_string()), + ..Default::default() + }; + inject_default_stdio_mcp_for_agent( + &mut runtime_contract, + "/tmp/project", + &mcp_config, + Some("swe"), + Some(&actor), + ); + + let args = default_serve_args(&runtime_contract); + let pos = args.iter().position(|a| a == "--actor-json").expect("default serve args must carry --actor-json"); + let decoded: Actor = serde_json::from_str(&args[pos + 1]).expect("actor arg must be valid JSON"); + assert_eq!(decoded, actor, "actor must round-trip through the --actor-json arg unchanged"); + } + + /// Without an actor the flag is absent → the server runs at global scope. + #[test] + fn inject_default_stdio_mcp_omits_actor_json_when_actor_absent() { + let mut runtime_contract = serde_json::json!({ + "cli": { "capabilities": { "supports_mcp": true } }, + "mcp": {} + }); + let mcp_config = protocol::McpRuntimeConfig { + stdio_command: Some("/usr/local/bin/animus".to_string()), + ..Default::default() + }; + inject_default_stdio_mcp_for_agent(&mut runtime_contract, "/tmp/project", &mcp_config, None, None); + + let args = default_serve_args(&runtime_contract); + assert!(!args.iter().any(|a| a == "--actor-json"), "no actor => no --actor-json flag (global scope)"); + } + /// Codex P2 #1 (mcp_config wire-through): when the host supplies a /// non-default `McpRuntimeConfig` with an explicit `stdio_command`, the /// stdio injection must honor it instead of falling back to a sibling diff --git a/crates/orchestrator-cli/src/cli_types/mcp_types.rs b/crates/orchestrator-cli/src/cli_types/mcp_types.rs index 4ee532bc..3ff81e0f 100644 --- a/crates/orchestrator-cli/src/cli_types/mcp_types.rs +++ b/crates/orchestrator-cli/src/cli_types/mcp_types.rs @@ -40,6 +40,16 @@ pub(crate) struct McpServeArgs { /// server should pass the running workflow id here. #[arg(long, value_name = "WORKFLOW_ID")] pub workflow_id: Option, + /// Bind this server to the transport-asserted caller identity for the run, + /// as a JSON-encoded `Actor` (`{"user_id","claims","tenant_id"}`). The host + /// that injects this server (the workflow runner, relaying the authenticated + /// run's actor) passes it so the tools can scope per-user integrations / + /// subject / queue ops. Omitted = global scope (system / local runs). + /// + /// TRUST BOUNDARY: set ONLY by the trusted host from the authenticated run; + /// never from agent output, workflow YAML, or subject content. + #[arg(long, value_name = "JSON")] + pub actor_json: Option, } #[derive(Debug, clap::Args)] diff --git a/crates/orchestrator-cli/src/cli_types/mod.rs b/crates/orchestrator-cli/src/cli_types/mod.rs index 2794ec24..d82555c3 100644 --- a/crates/orchestrator-cli/src/cli_types/mod.rs +++ b/crates/orchestrator-cli/src/cli_types/mod.rs @@ -883,6 +883,29 @@ mod tests { ); } + #[test] + fn mcp_serve_parses_actor_json_for_per_user_scoping() { + // WU-G: the workflow runner relays the authenticated run's actor to the + // per-agent `animus mcp serve` child via `--actor-json`. + let actor_json = r#"{"user_id":"alice","claims":["admin"],"tenant_id":"acme"}"#; + let cli = Cli::try_parse_from(["animus", "mcp", "serve", "--actor-json", actor_json]) + .expect("mcp serve --actor-json should parse"); + match cli.command { + Command::Mcp { command: McpCommand::Serve(args) } => { + assert_eq!(args.actor_json.as_deref(), Some(actor_json)); + assert_eq!(args.agent_id, None); + assert_eq!(args.workflow_id, None); + } + other => panic!("expected mcp serve, got {other:?}"), + } + // Bare `mcp serve` carries no actor (global scope). + let cli = Cli::try_parse_from(["animus", "mcp", "serve"]).expect("bare mcp serve should parse"); + match cli.command { + Command::Mcp { command: McpCommand::Serve(args) } => assert_eq!(args.actor_json, None), + other => panic!("expected mcp serve, got {other:?}"), + } + } + #[test] fn output_read_is_primary_and_run_alias_is_retired() { let cli = diff --git a/crates/orchestrator-cli/src/services/cost/enforcement.rs b/crates/orchestrator-cli/src/services/cost/enforcement.rs index e9548fd5..c330a735 100644 --- a/crates/orchestrator-cli/src/services/cost/enforcement.rs +++ b/crates/orchestrator-cli/src/services/cost/enforcement.rs @@ -512,7 +512,7 @@ mod tests { async fn running_workflow(hub: &Arc) -> String { let workflow = hub .workflows() - .run(WorkflowRunInput::for_task("TASK-BUDGET".to_string(), None)) + .run(WorkflowRunInput::for_task("TASK-BUDGET".to_string(), None), None) .await .expect("workflow should bootstrap"); assert_eq!(workflow.status, WorkflowStatus::Running); diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp.rs index c4475970..688e1f51 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp.rs @@ -1,6 +1,7 @@ #[cfg(test)] use crate::run_dir; use crate::McpCommand; +use animus_actor::Actor; use anyhow::Result; #[cfg(test)] use orchestrator_core::{OrchestratorWorkflow, WorkflowStateManager, WorkflowStatus}; @@ -184,6 +185,12 @@ struct AoMcpServer { // wait="suspend" and pending records carry this workflow id; overrides // both the env pin and the payload. pinned_workflow_id: Option, + // CLI-pinned, transport-asserted caller identity for the run + // (`animus mcp serve --actor-json `), relayed by the workflow runner + // from the authenticated run. Available to every tool via [`Self::pinned_actor`] + // so per-user integrations / subject / queue ops can scope to the user. + // `None` = global scope. NEVER sourced from agent output / YAML / subject content. + pinned_actor: Option, } impl std::fmt::Debug for AoMcpServer { @@ -262,7 +269,7 @@ pub(super) fn new_memory_mcp_server(default_project_root: &str) -> MemoryMcpServ #[cfg(test)] fn new_ao_mcp_server(default_project_root: &str) -> AoMcpServer { - new_ao_mcp_server_with_options(default_project_root, false, None, None) + new_ao_mcp_server_with_options(default_project_root, false, None, None, None) } // `management` gates the human-side `animus.interactions.*` tools. The default @@ -278,6 +285,7 @@ fn new_ao_mcp_server_with_options( management: bool, pinned_agent_id: Option, pinned_workflow_id: Option, + pinned_actor: Option, ) -> AoMcpServer { let mut tool_router = AoMcpServer::daemon_tool_router() + AoMcpServer::cost_tool_router() @@ -304,6 +312,7 @@ fn new_ao_mcp_server_with_options( plugin_registry: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), pinned_agent_id: pinned_agent_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), pinned_workflow_id: pinned_workflow_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), + pinned_actor, } } @@ -452,10 +461,27 @@ fn read_file_with_mtime(path: &Path) -> Result<(String, Option), std::io::E pub(crate) async fn handle_mcp(command: McpCommand, project_root: &str, cli_json: bool) -> Result<()> { match command { McpCommand::Serve(args) => { - let service = - new_ao_mcp_server_with_options(project_root, args.management, args.agent_id, args.workflow_id) - .serve(stdio()) - .await?; + // Parse the host-relayed actor (see McpServeArgs::actor_json). A + // malformed value is ignored (logged) → global scope, never a hard + // failure that would kill the agent's MCP server. TRUST BOUNDARY: + // this value is set ONLY by the trusted host (the workflow runner, + // relaying the authenticated run's actor) — never from agent output. + let pinned_actor = args.actor_json.as_deref().and_then(|raw| match serde_json::from_str::(raw) { + Ok(actor) => Some(actor), + Err(error) => { + tracing::warn!(%error, "ignoring malformed --actor-json; MCP server runs with no actor"); + None + } + }); + let service = new_ao_mcp_server_with_options( + project_root, + args.management, + args.agent_id, + args.workflow_id, + pinned_actor, + ) + .serve(stdio()) + .await?; service.waiting().await?; Ok(()) } diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/exec.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/exec.rs index 337c9c5b..b2273f75 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/exec.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/exec.rs @@ -1,16 +1,35 @@ use super::exec_errors::{batch_item_error_from_result, build_tool_error_payload, extract_cli_success_data}; use super::{build_guarded_list_result, AoMcpServer, BatchItemExec, ListGuardInput, OnError, BATCH_RESULT_SCHEMA}; +use animus_actor::Actor; use anyhow::Result; use rmcp::{model::CallToolResult, ErrorData as McpError}; use serde_json::{json, Value}; impl AoMcpServer { + /// The transport-asserted caller identity this server is bound to (from + /// `animus mcp serve --actor-json`, relayed by the workflow runner from the + /// authenticated run), or `None` for a global-scope / local server. + /// + /// Every tool call routes through [`Self::run_tool`] / [`Self::run_list_tool`], + /// which surface this for per-user audit. Per-user ENFORCEMENT (scoping the + /// underlying subject / queue / integration ops to the actor) is the + /// consumer-side change tracked separately — see the WU-G design note. + pub(super) fn pinned_actor(&self) -> Option<&Actor> { + self.pinned_actor.as_ref() + } + pub(super) async fn run_tool( &self, tool_name: &str, requested_args: Vec, project_root_override: Option, ) -> Result { + // Per-user audit: record which actor (if any) this tool call runs as. + // The actor reaches this server per-agent-spawn via `--actor-json`; this + // is the single choke point every typed tool routes through. + if let Some(actor) = self.pinned_actor() { + tracing::debug!(tool = tool_name, actor_user = %actor.user_id, "MCP tool invoked for actor"); + } match self.execute_ao(requested_args, project_root_override).await { Ok(result) => { if result.success { diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs index 22ec75a7..0df1223e 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/interaction_tools.rs @@ -1907,7 +1907,7 @@ mod interaction_tool_tests { .await .expect("task created"); hub.workflows() - .run(orchestrator_core::WorkflowRunInput::for_task(task.id, None)) + .run(orchestrator_core::WorkflowRunInput::for_task(task.id, None), None) .await .expect("workflow started") } @@ -1937,7 +1937,7 @@ mod interaction_tool_tests { ); } - let management = new_ao_mcp_server_with_options("/tmp/project", true, None, None); + let management = new_ao_mcp_server_with_options("/tmp/project", true, None, None, None); let names: Vec = management.tool_router.list_all().into_iter().map(|tool| tool.name.to_string()).collect(); for expected in [ @@ -2316,7 +2316,8 @@ phases: orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base( project.path(), ); - let server = new_ao_mcp_server_with_options(&project_root, false, Some("restricted".to_string()), None); + let server = + new_ao_mcp_server_with_options(&project_root, false, Some("restricted".to_string()), None, None); let result = server .ao_agent_request_approval(Parameters(AgentRequestApprovalInput { @@ -2557,7 +2558,7 @@ phases: let workflow = bootstrap_running_workflow(&project_root).await; assert_eq!(workflow.status, orchestrator_core::WorkflowStatus::Running); - let server = new_ao_mcp_server_with_options(&project_root, false, None, Some(workflow.id.clone())); + let server = new_ao_mcp_server_with_options(&project_root, false, None, Some(workflow.id.clone()), None); let result = server .ao_agent_ask(Parameters(AgentAskInput { agent_id: "swe".to_string(), @@ -2647,7 +2648,8 @@ phases: with_isolated_scope(async { let project = tempdir().expect("tempdir"); let project_root = project.path().to_string_lossy().to_string(); - let server = new_ao_mcp_server_with_options(&project_root, false, None, Some("wf-pinned".to_string())); + let server = + new_ao_mcp_server_with_options(&project_root, false, None, Some("wf-pinned".to_string()), None); let result = server .ao_agent_ask(Parameters(AgentAskInput { @@ -2971,7 +2973,7 @@ phases: orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base( project.path(), ); - let server = new_ao_mcp_server_with_options(&project_root, false, Some("swe".to_string()), None); + let server = new_ao_mcp_server_with_options(&project_root, false, Some("swe".to_string()), None, None); let original_input = serde_json::json!({ "command": "cargo test" }); let result = server @@ -3083,7 +3085,8 @@ phases: with_isolated_scope(async { let project = tempdir().expect("tempdir"); let project_root = project.path().to_string_lossy().to_string(); - let server = new_ao_mcp_server_with_options(&project_root, false, None, Some("wf-native".to_string())); + let server = + new_ao_mcp_server_with_options(&project_root, false, None, Some("wf-native".to_string()), None); let result = server .ao_agent_request_approval(Parameters(AgentRequestApprovalInput { diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs index d31d44e0..d7614daf 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs @@ -121,7 +121,7 @@ fn read_doc(relative_path: &str) -> String { // router. The default (agent-injected) server omits the two // `animus.interactions.*` management tools; the docs call that gating out. fn live_builtin_tool_names() -> BTreeSet { - let server = new_ao_mcp_server_with_options("/tmp/project", true, None, None); + let server = new_ao_mcp_server_with_options("/tmp/project", true, None, None, None); server.tool_router.list_all().into_iter().map(|tool| tool.name.to_string()).collect() } diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/tool_discovery_tools.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/tool_discovery_tools.rs index e66fa347..1b4370ce 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/tool_discovery_tools.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/tool_discovery_tools.rs @@ -404,7 +404,7 @@ mod tests { fn every_registered_tool_is_searchable_by_its_exact_name() { // Live-registry coverage: searching any registered tool's exact name // must return that tool first. Management mode = full built-in set. - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", true, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", true, None, None, None); let tools = server.tool_router.list_all(); assert!(!tools.is_empty(), "live tool router should not be empty"); for tool in &tools { @@ -422,7 +422,7 @@ mod tests { #[test] fn discovery_tools_appear_in_their_own_search_results() { - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None, None); let tools = server.tool_router.list_all(); let result = build_tools_search_result(&tools, "search tools registry", None).expect("search should succeed"); let names = match_names(&result); @@ -434,7 +434,7 @@ mod tests { #[test] fn tools_list_groups_the_live_registry_without_schemas() { - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None, None); let tools = server.tool_router.list_all(); let result = build_tools_list_result(&tools); assert_eq!(result.get("schema").and_then(Value::as_str), Some(TOOLS_LIST_SCHEMA)); @@ -470,7 +470,7 @@ mod tests { #[tokio::test] async fn tools_search_handler_returns_structured_result_through_dispatch_surface() { - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None, None); let result = server .ao_tools_search(Parameters(ToolsSearchInput { query: "animus.tools.search".to_string(), limit: Some(3) })) .await @@ -483,7 +483,7 @@ mod tests { #[tokio::test] async fn tools_search_handler_rejects_empty_query_as_structured_error() { - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None, None); let result = server .ao_tools_search(Parameters(ToolsSearchInput { query: " ".to_string(), limit: None })) .await @@ -493,7 +493,7 @@ mod tests { #[tokio::test] async fn tools_list_handler_returns_structured_catalog() { - let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None); + let server = super::super::new_ao_mcp_server_with_options("/tmp/project", false, None, None, None); let result = server.ao_tools_list(Parameters(ToolsListInput {})).await.expect("handler should succeed"); assert_ne!(result.is_error, Some(true)); let payload = result.structured_content.expect("structured content"); diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs index c552ff95..bdb6cadb 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/phases.rs @@ -244,7 +244,11 @@ pub(crate) async fn start_workflow_with_runner( overrides: DetachedRunnerOverrides, ) -> Result { ensure_workflow_runner_plugin(Path::new(project_root))?; - let workflow = hub.workflows().run(input).await?; + // Resolve the bootstrap (default workflow ref / phase plan / skip guards) + // from the authenticated actor's config partition so a user's private + // workflow resolves at run-time, not only from the global view. The same + // actor is relayed to the detached runner child via WORKFLOW_ACTOR_ENV. + let workflow = hub.workflows().run(input, overrides.actor.as_ref()).await?; // Mirror the daemon's ready-dispatch contract: a dispatched task moves // to InProgress. Terminal projection never auto-completes tasks, so a // task left Ready here would be re-dispatched by the daemon after its @@ -1128,7 +1132,7 @@ workflows: let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); let paused = hub.workflows().pause(&workflow.id).await.expect("workflow should pause"); @@ -1221,7 +1225,7 @@ workflows: let task = create_test_task(&hub, "reattach subject").await; let existing = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); @@ -1253,7 +1257,7 @@ workflows: // concurrent runner. let running = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); orchestrator_core::register_workflow_runner_pid(temp.path(), &running.id, std::process::id()) @@ -1416,7 +1420,7 @@ workflows: let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); let current_phase = workflow.current_phase.clone().expect("workflow should have current phase"); diff --git a/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs b/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs index 9249a471..0ade640e 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_workflow/prompt.rs @@ -509,6 +509,7 @@ mod tests { ) .with_input(Some(serde_json::json!({"ticket":"WF-7"}))) .with_vars(HashMap::from([("release_name".to_string(), "Mercury".to_string())])), + None, ) .await .expect("workflow should bootstrap"); @@ -535,11 +536,14 @@ mod tests { let hub = Arc::new(InMemoryServiceHub::new()); let workflow = hub .workflows() - .run(WorkflowRunInput::for_custom( - "Release Preview".to_string(), - "Inspect rendered prompt".to_string(), + .run( + WorkflowRunInput::for_custom( + "Release Preview".to_string(), + "Inspect rendered prompt".to_string(), + None, + ), None, - )) + ) .await .expect("workflow should bootstrap"); let mut args = base_args(); diff --git a/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs b/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs index ba3476eb..2c856de6 100644 --- a/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs +++ b/crates/orchestrator-cli/src/services/runtime/agent_mcp.rs @@ -144,6 +144,10 @@ pub(crate) fn assemble_agent_mcp_contract( &project_root.to_string_lossy(), &mcp_config, agent_id, + // Ad-hoc `animus chat` / `animus agent run` is a local, unauthenticated + // invocation — no transport actor. The workflow runner supplies the + // actor on the daemon-driven phase path; never synthesized here. + None, ); // Mirror the shared IPC path: when a stdio MCP command is injected diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_agent/interactions.rs b/crates/orchestrator-cli/src/services/runtime/runtime_agent/interactions.rs index 8128171a..6555ce0e 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_agent/interactions.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_agent/interactions.rs @@ -1407,7 +1407,7 @@ phases: .await .expect("task created"); hub.workflows() - .run(orchestrator_core::WorkflowRunInput::for_task(task.id, None)) + .run(orchestrator_core::WorkflowRunInput::for_task(task.id, None), None) .await .expect("workflow started") } diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_reconciliation.rs b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_reconciliation.rs index 18384d62..3b0aee4b 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_reconciliation.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_reconciliation.rs @@ -254,7 +254,7 @@ mod tests { .expect("task should be created"); let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); @@ -313,7 +313,7 @@ mod tests { .expect("task should be created"); let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), None)) + .run(WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should start"); hub.workflows().pause(&workflow.id).await.expect("workflow should pause"); diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_run.rs b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_run.rs index cc567b2d..5ede0e56 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_run.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_run.rs @@ -936,7 +936,7 @@ mod tests { let workflow = primary_hub .workflows() - .run(orchestrator_core::WorkflowRunInput::for_task(task.id.clone(), None)) + .run(orchestrator_core::WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("workflow should run"); // Cancel the workflow so all task workflows are terminal with no success. @@ -1786,7 +1786,10 @@ mod tests { .expect("create task"); let mut workflow = hub .workflows() - .run(orchestrator_core::WorkflowRunInput::for_task(task.id.clone(), workflow_ref.map(String::from))) + .run( + orchestrator_core::WorkflowRunInput::for_task(task.id.clone(), workflow_ref.map(String::from)), + None, + ) .await .expect("run workflow"); diff --git a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_tick_executor.rs b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_tick_executor.rs index 317053f4..25c31995 100644 --- a/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_tick_executor.rs +++ b/crates/orchestrator-cli/src/services/runtime/runtime_daemon/daemon_tick_executor.rs @@ -438,7 +438,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(20)).await; let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task_id.clone(), None)) + .run(WorkflowRunInput::for_task(task_id.clone(), None), None) .await .expect("workflow should start"); // Drive the workflow to a terminal Failed state (distinct from @@ -507,7 +507,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(20)).await; let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task_id.clone(), None)) + .run(WorkflowRunInput::for_task(task_id.clone(), None), None) .await .expect("workflow should start"); hub.workflows().cancel(&workflow.id).await.expect("workflow should cancel"); @@ -532,7 +532,7 @@ mod tests { hub.tasks().set_status(&task_id, TaskStatus::InProgress, false).await.expect("task should be in progress"); let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task_id.clone(), None)) + .run(WorkflowRunInput::for_task(task_id.clone(), None), None) .await .expect("workflow should start"); hub.workflows().cancel(&workflow.id).await.expect("workflow should cancel"); diff --git a/crates/orchestrator-config/src/agent_runtime_config.rs b/crates/orchestrator-config/src/agent_runtime_config.rs index c9281952..a363280f 100644 --- a/crates/orchestrator-config/src/agent_runtime_config.rs +++ b/crates/orchestrator-config/src/agent_runtime_config.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::OnceLock; +use animus_actor::Actor; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -504,7 +505,18 @@ pub fn load_agent_runtime_config(project_root: &Path) -> Result Result { - if let Ok(loaded_workflow) = crate::workflow_config::load_workflow_config_with_metadata(project_root, None) { + load_agent_runtime_config_with_metadata_for_actor(project_root, None) +} + +/// Actor-scoped variant of [`load_agent_runtime_config_with_metadata`]: derives +/// the agent runtime from the `actor`'s workflow-config partition so a per-user +/// `config_source` contributes that user's agents/runtime overlay. `actor = None` +/// is identical to the global path. +pub fn load_agent_runtime_config_with_metadata_for_actor( + project_root: &Path, + actor: Option<&Actor>, +) -> Result { + if let Ok(loaded_workflow) = crate::workflow_config::load_workflow_config_with_metadata(project_root, actor) { let mut config = builtin_agent_runtime_config(); let registry = crate::resolve_pack_registry(project_root)?; let mut path = loaded_workflow.path.clone(); @@ -552,7 +564,16 @@ pub fn load_agent_runtime_config_with_metadata(project_root: &Path) -> Result AgentRuntimeConfig { - match load_agent_runtime_config_with_metadata(project_root) { + load_agent_runtime_config_or_default_for_actor(project_root, None) +} + +/// Actor-scoped variant of [`load_agent_runtime_config_or_default`]. +/// `actor = None` is identical to the global path. +pub fn load_agent_runtime_config_or_default_for_actor( + project_root: &Path, + actor: Option<&Actor>, +) -> AgentRuntimeConfig { + match load_agent_runtime_config_with_metadata_for_actor(project_root, actor) { Ok(loaded) => loaded.config, Err(_) => builtin_agent_runtime_config(), } diff --git a/crates/orchestrator-config/src/workflow_config/config_source_client.rs b/crates/orchestrator-config/src/workflow_config/config_source_client.rs index 56e12c10..2de189b3 100644 --- a/crates/orchestrator-config/src/workflow_config/config_source_client.rs +++ b/crates/orchestrator-config/src/workflow_config/config_source_client.rs @@ -225,7 +225,7 @@ pub fn resolve_plugin_base(project_root: &Path, actor: Option<&Actor>) -> Result // unit tests can exercise the kernel's pack-merge + validate pipeline (which // it still owns) without spawning a real plugin process. #[cfg(any(test, feature = "test-utils"))] - if let Some(base) = test_seam::base_for(project_root) { + if let Some(base) = test_seam::base_for(project_root, actor) { // Derive the CacheToken from the base content so the compiled-config // short-circuit in `loading.rs` recompiles when a test reinstalls a // DIFFERENT base for the same root. A constant token would serve a @@ -588,7 +588,9 @@ pub mod test_seam { use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; - use super::WorkflowConfig; + use animus_actor::Actor; + + use super::{actor_cache_key, WorkflowConfig}; // Process-global registry keyed by project root, so the seam is safe under // cargo's parallel test execution (daemon/runtime tests load config on @@ -600,6 +602,15 @@ pub mod test_seam { REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } + // Actor-scoped registry keyed by (project root, actor partition). Lets a + // test simulate a per-user `config_source` whose base differs per actor + // (e.g. a private workflow visible only to one user). Looked up before the + // root-only `registry()` when an actor is present. + fn actor_registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) + } + // Normalize the key so a test that installs against `tempdir().path()` is // still found when the loader resolves the project root via git-common-root // (which canonicalizes, e.g. /var -> /private/var on macOS). Falls back to @@ -617,9 +628,28 @@ pub mod test_seam { TestBaseGuard { key } } - /// Clone the synthetic base installed for `project_root`, if any. - pub fn base_for(project_root: &Path) -> Option { - registry().lock().unwrap_or_else(|p| p.into_inner()).get(&normalize(project_root)).cloned() + /// Install a synthetic base config for `(project_root, actor)`, simulating a + /// per-user `config_source` partition. The override is active until the + /// returned guard is dropped. + #[must_use] + pub fn install_for_actor(project_root: &Path, actor: &Actor, base: WorkflowConfig) -> ActorTestBaseGuard { + let key = (normalize(project_root), actor_cache_key(Some(actor))); + actor_registry().lock().unwrap_or_else(|p| p.into_inner()).insert(key.clone(), base); + ActorTestBaseGuard { key } + } + + /// Clone the synthetic base installed for `(project_root, actor)`. Prefers an + /// actor-scoped base (when `actor` is present and one was installed), else + /// falls back to the root-only base. + pub fn base_for(project_root: &Path, actor: Option<&Actor>) -> Option { + let root = normalize(project_root); + if actor.is_some() { + let actor_key = (root.clone(), actor_cache_key(actor)); + if let Some(base) = actor_registry().lock().unwrap_or_else(|p| p.into_inner()).get(&actor_key).cloned() { + return Some(base); + } + } + registry().lock().unwrap_or_else(|p| p.into_inner()).get(&root).cloned() } /// RAII guard that removes the installed base for its project root on drop. @@ -632,6 +662,17 @@ pub mod test_seam { registry().lock().unwrap_or_else(|p| p.into_inner()).remove(&self.key); } } + + /// RAII guard that removes the installed `(root, actor)` base on drop. + pub struct ActorTestBaseGuard { + key: (PathBuf, String), + } + + impl Drop for ActorTestBaseGuard { + fn drop(&mut self) { + actor_registry().lock().unwrap_or_else(|p| p.into_inner()).remove(&self.key); + } + } } /// v0.6 cross-crate test seam: stand in for an installed `config_source` plugin diff --git a/crates/orchestrator-config/src/workflow_config/loading.rs b/crates/orchestrator-config/src/workflow_config/loading.rs index 21adb4fb..4eae1fda 100644 --- a/crates/orchestrator-config/src/workflow_config/loading.rs +++ b/crates/orchestrator-config/src/workflow_config/loading.rs @@ -275,7 +275,15 @@ pub fn load_workflow_config_or_default(project_root: &Path) -> LoadedWorkflowCon // The default/fallback path is system-initiated (daemon reconcilers, // schedulers, CLI inspection) with no authenticated actor → the global // (`actor = None`) config partition. - match load_workflow_config_with_metadata(project_root, None) { + load_workflow_config_or_default_for_actor(project_root, None) +} + +/// Actor-scoped variant of [`load_workflow_config_or_default`]: resolves the +/// config from the `actor`'s partition (a per-user `config_source` returns that +/// user's global∪private∪shared set), falling back to the builtin base on error. +/// `actor = None` is identical to [`load_workflow_config_or_default`] (global). +pub fn load_workflow_config_or_default_for_actor(project_root: &Path, actor: Option<&Actor>) -> LoadedWorkflowConfig { + match load_workflow_config_with_metadata(project_root, actor) { Ok(loaded) => loaded, Err(_) => { let config = runtime_workflow_config_base(); diff --git a/crates/orchestrator-config/src/workflow_config/mod.rs b/crates/orchestrator-config/src/workflow_config/mod.rs index 374a39d9..85dc7fdb 100644 --- a/crates/orchestrator-config/src/workflow_config/mod.rs +++ b/crates/orchestrator-config/src/workflow_config/mod.rs @@ -65,8 +65,8 @@ pub use config_write::{ }; pub use loading::{ ensure_workflow_config_compiled, ensure_workflow_config_file, legacy_workflow_config_paths, load_workflow_config, - load_workflow_config_or_default, load_workflow_config_with_metadata, workflow_config_hash, workflow_config_path, - write_workflow_config, + load_workflow_config_or_default, load_workflow_config_or_default_for_actor, load_workflow_config_with_metadata, + workflow_config_hash, workflow_config_path, write_workflow_config, }; pub use resolution::{ resolve_workflow_phase_plan, resolve_workflow_rework_attempts, resolve_workflow_skip_guards, diff --git a/crates/orchestrator-core/Cargo.toml b/crates/orchestrator-core/Cargo.toml index 4e256a0e..033cb929 100644 --- a/crates/orchestrator-core/Cargo.toml +++ b/crates/orchestrator-core/Cargo.toml @@ -18,6 +18,7 @@ rusqlite = { version = "0.31", features = ["bundled", "blob"] } zstd = "0.13" tempfile = "3" protocol = { workspace = true } +animus-actor = { workspace = true } animus-plugin-protocol = { workspace = true } orchestrator-plugin-host = { workspace = true } orchestrator-config = { workspace = true } diff --git a/crates/orchestrator-core/src/lib.rs b/crates/orchestrator-core/src/lib.rs index eee6a93d..12db95ca 100644 --- a/crates/orchestrator-core/src/lib.rs +++ b/crates/orchestrator-core/src/lib.rs @@ -28,11 +28,11 @@ pub mod workflow_runner_registry; pub use agent_runtime_config::{ agent_runtime_config_path, builtin_agent_runtime_config, ensure_agent_runtime_config_file, - load_agent_runtime_config, load_agent_runtime_config_or_default, write_agent_runtime_config, AgentProfile, - AgentRuntimeConfig, AgentRuntimeMetadata, AgentRuntimeOverrides, AgentRuntimeSource, BackoffConfig, CliToolConfig, - CommandCwdMode, Idempotency, LoadedAgentRuntimeConfig, PhaseCommandDefinition, PhaseDecisionContract, - PhaseExecutionDefinition, PhaseExecutionMode, PhaseManualDefinition, PhaseOutputContract, PhaseRetryConfig, - DEFAULT_MAX_REWORK_ATTEMPTS, + load_agent_runtime_config, load_agent_runtime_config_or_default, load_agent_runtime_config_or_default_for_actor, + write_agent_runtime_config, AgentProfile, AgentRuntimeConfig, AgentRuntimeMetadata, AgentRuntimeOverrides, + AgentRuntimeSource, BackoffConfig, CliToolConfig, CommandCwdMode, Idempotency, LoadedAgentRuntimeConfig, + PhaseCommandDefinition, PhaseDecisionContract, PhaseExecutionDefinition, PhaseExecutionMode, PhaseManualDefinition, + PhaseOutputContract, PhaseRetryConfig, DEFAULT_MAX_REWORK_ATTEMPTS, }; pub use config::RuntimeConfig; pub use daemon_config::{ @@ -143,10 +143,10 @@ pub use workflow::{ load_task_priority_policy_report, load_task_statistics, load_task_titles_by_ids, load_tasks_by_ids, load_workflow_history_summaries, load_workflow_ref_index, migrate_tasks_and_requirements_from_core_state, open_project_db, phase_plan_for_workflow_ref, query_requirement_ids, query_task_ids, - resolve_phase_plan_for_workflow_ref, save_requirement, save_task, BlockedTaskSummary, CleanupResult, - RequirementLinkSummary, ResumabilityStatus, ResumeConfig, StaleTaskSummary, WorkflowActivitySummary, - WorkflowCheckpointPruneResult, WorkflowFailureSummary, WorkflowHistorySummary, WorkflowLifecycleExecutor, - WorkflowResumeManager, WorkflowStateMachine, WorkflowStateManager, + resolve_phase_plan_for_workflow_ref, resolve_phase_plan_for_workflow_ref_for_actor, save_requirement, save_task, + BlockedTaskSummary, CleanupResult, RequirementLinkSummary, ResumabilityStatus, ResumeConfig, StaleTaskSummary, + WorkflowActivitySummary, WorkflowCheckpointPruneResult, WorkflowFailureSummary, WorkflowHistorySummary, + WorkflowLifecycleExecutor, WorkflowResumeManager, WorkflowStateMachine, WorkflowStateManager, DEFAULT_CHECKPOINT_RETENTION_KEEP_LAST_PER_PHASE, STANDARD_WORKFLOW_REF, UI_UX_WORKFLOW_REF, }; pub use workflow::{ @@ -156,14 +156,14 @@ pub use workflow::{ pub use workflow_config::{ builtin_workflow_config, compile_yaml_workflow_files, ensure_workflow_config_compiled, ensure_workflow_config_file, expand_variables, expand_workflow_phases, generated_workflow_phase_is_defined, legacy_workflow_config_paths, - load_workflow_config, load_workflow_config_or_default, load_workflow_config_with_metadata, merge_yaml_into_config, - missing_project_skill_reference_warnings, missing_skill_reference_warnings_for_sources, - missing_skill_yaml_warnings, parse_yaml_workflow_config, remove_agent_profile, remove_generated_workflow_phase, - remove_workflow_definition, resolve_workflow_phase_plan, resolve_workflow_rework_attempts, - resolve_workflow_skip_guards, resolve_workflow_variables, resolve_workflow_verdict_routing, - unenforced_project_yaml_warnings, unenforced_yaml_field_warnings, upsert_agent_profile, - upsert_generated_workflow_phase, upsert_generated_workflow_pipeline, upsert_workflow_definition, - validate_and_compile_yaml_workflows, validate_workflow_and_runtime_configs, + load_workflow_config, load_workflow_config_or_default, load_workflow_config_or_default_for_actor, + load_workflow_config_with_metadata, merge_yaml_into_config, missing_project_skill_reference_warnings, + missing_skill_reference_warnings_for_sources, missing_skill_yaml_warnings, parse_yaml_workflow_config, + remove_agent_profile, remove_generated_workflow_phase, remove_workflow_definition, resolve_workflow_phase_plan, + resolve_workflow_rework_attempts, resolve_workflow_skip_guards, resolve_workflow_variables, + resolve_workflow_verdict_routing, unenforced_project_yaml_warnings, unenforced_yaml_field_warnings, + upsert_agent_profile, upsert_generated_workflow_phase, upsert_generated_workflow_pipeline, + upsert_workflow_definition, validate_and_compile_yaml_workflows, validate_workflow_and_runtime_configs, validate_workflow_and_runtime_configs_with_project_root, validate_workflow_config, workflow_config_hash, workflow_config_path, write_full_workflow_config, write_workflow_config, yaml_workflows_dir, CompileYamlResult, FileWatcherTriggerConfig, LoadedWorkflowConfig, PhaseMcpBinding, PhaseTransitionConfig, PhaseUiDefinition, diff --git a/crates/orchestrator-core/src/services.rs b/crates/orchestrator-core/src/services.rs index b911fdb2..1a7cc8dd 100644 --- a/crates/orchestrator-core/src/services.rs +++ b/crates/orchestrator-core/src/services.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use crate::store::{write_json_if_missing, write_json_pretty}; use crate::types::not_found; +use animus_actor::Actor; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use chrono::Utc; @@ -158,7 +159,13 @@ pub trait WorkflowServiceApi: Send + Sync { async fn decisions(&self, id: &str) -> Result>; async fn list_checkpoints(&self, id: &str) -> Result>; async fn get_checkpoint(&self, id: &str, checkpoint_number: usize) -> Result; - async fn run(&self, input: WorkflowRunInput) -> Result; + /// Bootstrap a workflow run. `actor` is the transport-asserted caller + /// identity (from the authenticated control request); it scopes the + /// default-workflow-ref / phase-plan / skip-guard resolution to the actor's + /// `config_source` partition. `None` = the global partition (system-initiated + /// runs, local CLI). The actor is NEVER synthesized from workflow YAML, agent + /// output, or subject content. + async fn run(&self, input: WorkflowRunInput, actor: Option<&Actor>) -> Result; async fn resume(&self, id: &str) -> Result; async fn pause(&self, id: &str) -> Result; async fn cancel(&self, id: &str) -> Result; diff --git a/crates/orchestrator-core/src/services/tests.rs b/crates/orchestrator-core/src/services/tests.rs index f6a05179..a786eff9 100644 --- a/crates/orchestrator-core/src/services/tests.rs +++ b/crates/orchestrator-core/src/services/tests.rs @@ -669,6 +669,7 @@ workflows: let workflow = WorkflowServiceApi::run( &hub, WorkflowRunInput::for_task("TASK-yaml-only".to_string(), Some("yaml-only-workflow".to_string())), + None, ) .await .expect("workflow should start in yaml-only repo"); @@ -679,16 +680,70 @@ workflows: assert!(!scoped.join("state").join("workflow-config.v2.json").exists(), "execution must not generate JSON config"); } +#[tokio::test] +async fn file_hub_run_persists_actor_for_lifecycle_continuation() { + // Codex P1: the actor a run bootstraps with must persist so EVERY later + // lifecycle transition (resume/complete/cancel/...) re-resolves config from + // the same partition — otherwise an actor-private workflow starts then fails + // at the next transition (global re-lookup). run() persists it on the run row. + let temp = tempfile::tempdir().expect("tempdir"); + let hub = file_hub(temp.path()).expect("create hub"); + let _config_source_seam = + orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); + let actor = animus_actor::Actor { + user_id: "alice".to_string(), + claims: vec!["admin".to_string()], + tenant_id: Some("acme".to_string()), + }; + let workflow = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-actor".to_string(), Some("standard".to_string())), + Some(&actor), + ) + .await + .expect("run workflow"); + + let manager = crate::WorkflowStateManager::new(temp.path()); + assert_eq!( + manager.load_workflow_actor(&workflow.id), + Some(actor.clone()), + "run() must persist the bootstrap actor for lifecycle continuation" + ); + + // The actor MUST survive a lifecycle save: `save()` upserts (not INSERT OR + // REPLACE), preserving the `actor` column. Without that, the first pause/ + // resume/complete would NULL it and later transitions fall back to global. + WorkflowServiceApi::pause(&hub, &workflow.id).await.expect("pause workflow"); + assert_eq!( + manager.load_workflow_actor(&workflow.id), + Some(actor), + "actor must survive a lifecycle save (upsert preserves the actor column)" + ); + + // A system-initiated run (actor = None) persists no actor (global scope). + let global = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-global".to_string(), Some("standard".to_string())), + None, + ) + .await + .expect("run workflow"); + assert_eq!(manager.load_workflow_actor(&global.id), None, "actor = None must persist nothing (global scope)"); +} + #[tokio::test] async fn file_hub_persists_workflows_with_machine_state() { let temp = tempfile::tempdir().expect("tempdir"); let hub = file_hub(temp.path()).expect("create hub"); let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let workflow = - WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task("TASK-1".to_string(), Some("standard".to_string()))) - .await - .expect("run workflow"); + let workflow = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-1".to_string(), Some("standard".to_string())), + None, + ) + .await + .expect("run workflow"); assert_eq!(workflow.status, WorkflowStatus::Running); assert_eq!(workflow.machine_state, crate::types::WorkflowMachineState::RunPhase); @@ -788,6 +843,7 @@ async fn file_hub_complete_phase_with_decision_honors_rework_routing() { let workflow = WorkflowServiceApi::run( &hub, WorkflowRunInput::for_task("TASK-routed-rework".to_string(), Some("routed-rework".to_string())), + None, ) .await .expect("run workflow"); @@ -841,6 +897,7 @@ async fn file_hub_auto_prunes_checkpoints_on_completion_when_enabled() { let mut workflow = WorkflowServiceApi::run( &hub, WorkflowRunInput::for_task("TASK-prune".to_string(), Some("standard".to_string())), + None, ) .await .expect("run workflow"); @@ -873,6 +930,7 @@ async fn file_hub_completion_remains_successful_when_auto_prune_errors() { let mut workflow = WorkflowServiceApi::run( &hub, WorkflowRunInput::for_task("TASK-prune-error".to_string(), Some("standard".to_string())), + None, ) .await .expect("run workflow"); @@ -1048,10 +1106,13 @@ async fn file_hub_uses_custom_pipeline_from_workflow_config_v2() { let hub = file_hub(temp.path()).expect("create hub"); let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let workflow = - WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task("TASK-1".to_string(), Some("xhigh-dev".to_string()))) - .await - .expect("run workflow"); + let workflow = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-1".to_string(), Some("xhigh-dev".to_string())), + None, + ) + .await + .expect("run workflow"); let phase_ids = workflow.phases.iter().map(|phase| phase.phase_id.as_str()).collect::>(); assert_eq!(phase_ids, vec!["requirements", "implementation", "code-review", "testing", "qa-signoff"]); @@ -1078,6 +1139,7 @@ async fn file_hub_errors_when_requested_pipeline_is_missing_from_config() { let err = WorkflowServiceApi::run( &hub, WorkflowRunInput::for_task("TASK-1".to_string(), Some("missing-pipeline".to_string())), + None, ) .await .expect_err("unknown pipeline should fail when workflow config exists"); @@ -1577,10 +1639,13 @@ async fn workflow_service_exposes_decisions_and_checkpoints() { let hub = file_hub(temp.path()).expect("create hub"); let _config_source_seam = orchestrator_config::workflow_config::config_source_client::install_yaml_config_source_base(temp.path()); - let workflow = - WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task("TASK-123".to_string(), Some("standard".to_string()))) - .await - .expect("run workflow"); + let workflow = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-123".to_string(), Some("standard".to_string())), + None, + ) + .await + .expect("run workflow"); let workflow = WorkflowServiceApi::complete_current_phase(&hub, &workflow.id).await.expect("complete current phase"); @@ -1751,14 +1816,20 @@ async fn planning_service_query_filters_and_sorts_requirements() { async fn workflow_service_query_filters_by_status_and_reference() { let hub = InMemoryServiceHub::new(); - let first = - WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task("TASK-100".to_string(), Some("standard".to_string()))) - .await - .expect("first workflow should start"); - let second = - WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task("TASK-200".to_string(), Some("ui-ux".to_string()))) - .await - .expect("second workflow should start"); + let first = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-100".to_string(), Some("standard".to_string())), + None, + ) + .await + .expect("first workflow should start"); + let second = WorkflowServiceApi::run( + &hub, + WorkflowRunInput::for_task("TASK-200".to_string(), Some("ui-ux".to_string())), + None, + ) + .await + .expect("second workflow should start"); WorkflowServiceApi::pause(&hub, &first.id).await.expect("first workflow should pause"); WorkflowServiceApi::cancel(&hub, &second.id).await.expect("second workflow should cancel"); @@ -2557,7 +2628,7 @@ async fn execute_requirements_skips_tasks_with_active_workflow_on_file_hub() { .await .expect("upsert requirement"); - let active = WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task(task.id.clone(), None)) + let active = WorkflowServiceApi::run(&hub, WorkflowRunInput::for_task(task.id.clone(), None), None) .await .expect("start workflow for task"); assert_eq!(active.status, WorkflowStatus::Running); @@ -2674,7 +2745,7 @@ async fn manual_phase_approval_resume_clears_task_pause_marker() { .expect("create task"); let workflow = hub .workflows() - .run(WorkflowRunInput::for_task(task.id.clone(), Some("manual-only".to_string()))) + .run(WorkflowRunInput::for_task(task.id.clone(), Some("manual-only".to_string())), None) .await .expect("run workflow"); assert_eq!(workflow.current_phase.as_deref(), Some("qa-signoff")); diff --git a/crates/orchestrator-core/src/services/workflow_impl.rs b/crates/orchestrator-core/src/services/workflow_impl.rs index ce11b248..6a77f7c9 100644 --- a/crates/orchestrator-core/src/services/workflow_impl.rs +++ b/crates/orchestrator-core/src/services/workflow_impl.rs @@ -1,5 +1,6 @@ use super::*; use crate::types::PhaseDecision; +use animus_actor::Actor; use super::query_support::paginate_items; @@ -21,8 +22,12 @@ fn effective_workflow_ref( fn load_phase_retry_configs( project_root: &std::path::Path, + actor: Option<&Actor>, ) -> std::collections::HashMap { - let config = crate::agent_runtime_config::load_agent_runtime_config_or_default(project_root); + // Scope retry/backoff policy to the actor partition so a per-user config + // source's `max_attempts` / backoff overrides apply (matches the workflow + // config + phase-plan resolution). `actor = None` = global. + let config = crate::agent_runtime_config::load_agent_runtime_config_or_default_for_actor(project_root, actor); config .phases .iter() @@ -185,16 +190,17 @@ impl WorkflowServiceApi for InMemoryServiceHub { } } - async fn run(&self, input: WorkflowRunInput) -> Result { + async fn run(&self, input: WorkflowRunInput, actor: Option<&Actor>) -> Result { let id = Uuid::new_v4().to_string(); let workflow = { let mut lock = self.state.write().await; let task = input.subject.task_id().and_then(|id| lock.tasks.get(id).cloned()); let workflow_ref = effective_workflow_ref(input.workflow_ref(), crate::workflow::STANDARD_WORKFLOW_REF, task.as_ref()); - let executor = WorkflowLifecycleExecutor::new(crate::resolve_phase_plan_for_workflow_ref( + let executor = WorkflowLifecycleExecutor::new(crate::resolve_phase_plan_for_workflow_ref_for_actor( None, Some(workflow_ref.as_str()), + actor, )?); let workflow = executor.bootstrap(id.clone(), input.with_workflow_ref(workflow_ref)); lock.workflows.insert(id.clone(), workflow.clone()); @@ -393,11 +399,14 @@ impl WorkflowServiceApi for FileServiceHub { self.workflow_manager().load_checkpoint(id, checkpoint_number) } - async fn run(&self, input: WorkflowRunInput) -> Result { + async fn run(&self, input: WorkflowRunInput, actor: Option<&Actor>) -> Result { let id = Uuid::new_v4().to_string(); let state_machines = load_compiled_state_machines(self.project_root.as_path())?; - let retry_configs = load_phase_retry_configs(self.project_root.as_path()); - let workflow_config = crate::load_workflow_config_or_default(self.project_root.as_path()); + let retry_configs = load_phase_retry_configs(self.project_root.as_path(), actor); + // Resolve the bootstrap config from the actor's partition so a per-user + // `config_source` (global∪private∪shared) contributes the default + // workflow ref, skip guards, and phase plan. `actor = None` = global. + let workflow_config = crate::load_workflow_config_or_default_for_actor(self.project_root.as_path(), actor); let task = if let Some(task_id) = input.subject.task_id() { crate::workflow::load_task(&self.project_root, task_id).ok() } else { @@ -407,7 +416,11 @@ impl WorkflowServiceApi for FileServiceHub { effective_workflow_ref(input.workflow_ref(), &workflow_config.config.default_workflow_ref, task.as_ref()); let skip_guards = crate::resolve_workflow_skip_guards(&workflow_config.config, Some(workflow_ref.as_str())); let executor = WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref(Some(self.project_root.as_path()), Some(workflow_ref.as_str()))?, + crate::resolve_phase_plan_for_workflow_ref_for_actor( + Some(self.project_root.as_path()), + Some(workflow_ref.as_str()), + actor, + )?, state_machines, ) .with_retry_configs(retry_configs) @@ -422,6 +435,11 @@ impl WorkflowServiceApi for FileServiceHub { let manager = self.workflow_manager(); manager.save(&workflow)?; let workflow = manager.save_checkpoint(&workflow, CheckpointReason::Start)?; + // Persist the actor partition so EVERY later lifecycle transition + // (resume/complete/cancel/...) re-resolves config from the same partition + // this run bootstrapped with — otherwise an actor-private workflow would + // start, then fail or mis-route at the next transition (global lookup). + manager.set_workflow_actor(&workflow.id, actor)?; self.state.write().await.workflows.insert(id, workflow.clone()); Ok(workflow) @@ -432,9 +450,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; let executor = WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ); @@ -451,9 +470,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ) @@ -469,9 +489,10 @@ impl WorkflowServiceApi for FileServiceHub { let manager = self.workflow_manager(); let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; - let phase_plan = crate::resolve_phase_plan_for_workflow_ref( + let phase_plan = crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), ) .unwrap_or_default(); WorkflowLifecycleExecutor::with_state_machines(phase_plan, state_machines).cancel(&mut workflow); @@ -493,17 +514,22 @@ impl WorkflowServiceApi for FileServiceHub { ) -> Result { let manager = self.workflow_manager(); let mut workflow = manager.load(id)?; + // The run's persisted actor partition: re-resolve config / phase plan / + // retry policy from the SAME partition the run bootstrapped with. + let actor = manager.load_workflow_actor(id); let state_machines = load_compiled_state_machines(self.project_root.as_path())?; - let retry_configs = load_phase_retry_configs(self.project_root.as_path()); - let workflow_config = crate::load_workflow_config_or_default(self.project_root.as_path()); + let retry_configs = load_phase_retry_configs(self.project_root.as_path(), actor.as_ref()); + let workflow_config = + crate::load_workflow_config_or_default_for_actor(self.project_root.as_path(), actor.as_ref()); let verdict_routing = crate::resolve_workflow_verdict_routing(&workflow_config.config, workflow.workflow_ref.as_deref()); let skip_guards = crate::resolve_workflow_skip_guards(&workflow_config.config, workflow.workflow_ref.as_deref()); let executor = WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + actor.as_ref(), )?, state_machines, ) @@ -562,9 +588,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ) @@ -581,9 +608,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ) @@ -600,9 +628,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ) @@ -619,9 +648,10 @@ impl WorkflowServiceApi for FileServiceHub { let mut workflow = manager.load(id)?; let state_machines = load_compiled_state_machines(self.project_root.as_path())?; WorkflowLifecycleExecutor::with_state_machines( - crate::resolve_phase_plan_for_workflow_ref( + crate::resolve_phase_plan_for_workflow_ref_for_actor( Some(self.project_root.as_path()), workflow.workflow_ref.as_deref(), + manager.load_workflow_actor(id).as_ref(), )?, state_machines, ) diff --git a/crates/orchestrator-core/src/workflow.rs b/crates/orchestrator-core/src/workflow.rs index 74cfd03c..539859bc 100644 --- a/crates/orchestrator-core/src/workflow.rs +++ b/crates/orchestrator-core/src/workflow.rs @@ -6,7 +6,8 @@ mod state_manager; pub use lifecycle_executor::WorkflowLifecycleExecutor; pub use phase_plan::{ - phase_plan_for_workflow_ref, resolve_phase_plan_for_workflow_ref, STANDARD_WORKFLOW_REF, UI_UX_WORKFLOW_REF, + phase_plan_for_workflow_ref, resolve_phase_plan_for_workflow_ref, resolve_phase_plan_for_workflow_ref_for_actor, + STANDARD_WORKFLOW_REF, UI_UX_WORKFLOW_REF, }; pub use resume::{ResumabilityStatus, ResumeConfig, WorkflowResumeManager}; pub use state_machine::WorkflowStateMachine; diff --git a/crates/orchestrator-core/src/workflow/phase_plan.rs b/crates/orchestrator-core/src/workflow/phase_plan.rs index fd3d01e9..a8c0c0ca 100644 --- a/crates/orchestrator-core/src/workflow/phase_plan.rs +++ b/crates/orchestrator-core/src/workflow/phase_plan.rs @@ -1,5 +1,6 @@ use std::path::Path; +use animus_actor::Actor; use anyhow::{anyhow, Result}; pub const STANDARD_WORKFLOW_REF: &str = "standard-workflow"; @@ -58,6 +59,19 @@ pub fn phase_plan_for_workflow_ref(workflow_ref: Option<&str>) -> Vec { pub fn resolve_phase_plan_for_workflow_ref( project_root: Option<&Path>, workflow_ref: Option<&str>, +) -> Result> { + resolve_phase_plan_for_workflow_ref_for_actor(project_root, workflow_ref, None) +} + +/// Actor-scoped variant of [`resolve_phase_plan_for_workflow_ref`]: resolves the +/// phase plan (and validates against the agent-runtime config) from the +/// `actor`'s config partition, so a workflow definition that exists only in a +/// user's private/shared set resolves at run-time for that actor. `actor = None` +/// is identical to today's global behavior. +pub fn resolve_phase_plan_for_workflow_ref_for_actor( + project_root: Option<&Path>, + workflow_ref: Option<&str>, + actor: Option<&Actor>, ) -> Result> { let requested_workflow_ref = raw_requested_workflow_ref(workflow_ref); let normalized_workflow_ref = normalize_requested_workflow_ref(workflow_ref); @@ -97,9 +111,9 @@ pub fn resolve_phase_plan_for_workflow_ref( )); } - let loaded_workflow = crate::load_workflow_config_with_metadata(root, None)?; + let loaded_workflow = crate::load_workflow_config_with_metadata(root, actor)?; let workflow_config = loaded_workflow.config; - let runtime_config = crate::load_agent_runtime_config_or_default(root); + let runtime_config = crate::load_agent_runtime_config_or_default_for_actor(root, actor); crate::validate_workflow_and_runtime_configs_with_project_root(&workflow_config, &runtime_config, Some(root))?; if let Some(phases) = crate::resolve_workflow_phase_plan(&workflow_config, requested_workflow_ref.as_deref()) { @@ -460,4 +474,59 @@ workflows: .expect_err("review pack workflow should not resolve until the pack is installed"); assert!(review_cycle_error.to_string().contains("is not available until the project defines workflows")); } + + #[test] + fn resolve_phase_plan_resolves_actor_private_workflow_only_for_that_actor() { + use orchestrator_config::workflow_config::config_source_client::test_seam; + use orchestrator_config::WorkflowDefinition; + + let _home_lock = ensure_stable_home(); + let temp = tempfile::tempdir().expect("tempdir"); + + // Global base: standard + ui-ux only (NO private workflow). + let global_config = test_workflow_config_with_standard_pipeline(); + // Alice's partition base: the same, PLUS a workflow visible only to her. + let mut alice_config = test_workflow_config_with_standard_pipeline(); + alice_config.workflows.push(WorkflowDefinition { + id: "alice-private".to_string(), + name: "Alice Private".to_string(), + description: "Visible only in alice's config_source partition.".to_string(), + phases: vec!["requirements".to_string().into(), "implementation".to_string().into()], + variables: Vec::new(), + worktree: None, + budget: None, + }); + + // Pass the resolver's source gate (has_yaml_workflows) by writing a config + // to disk, then inject the per-partition bases through the test seam: the + // root-only base is the global view, the actor base is alice's view. + crate::write_workflow_config(temp.path(), &global_config).expect("write workflow config"); + let _global = test_seam::install(temp.path(), global_config); + let alice = animus_actor::Actor { user_id: "alice".into(), claims: Vec::new(), tenant_id: None }; + let bob = animus_actor::Actor { user_id: "bob".into(), claims: Vec::new(), tenant_id: None }; + let _alice_base = test_seam::install_for_actor(temp.path(), &alice, alice_config); + + // Alice resolves her private workflow from her partition. + let phases = + resolve_phase_plan_for_workflow_ref_for_actor(Some(temp.path()), Some("alice-private"), Some(&alice)) + .expect("alice's private workflow must resolve from her partition"); + assert_eq!(phases, vec!["requirements".to_string(), "implementation".to_string()]); + + // The global (actor = None) partition must NOT contain it (no cross-leak). + assert!( + resolve_phase_plan_for_workflow_ref_for_actor(Some(temp.path()), Some("alice-private"), None).is_err(), + "the global partition must not resolve alice's private workflow" + ); + // Another actor (bob) falls back to the global view → also cannot see it. + assert!( + resolve_phase_plan_for_workflow_ref_for_actor(Some(temp.path()), Some("alice-private"), Some(&bob)) + .is_err(), + "bob must not resolve alice's private workflow" + ); + // Sanity: the shared standard workflow still resolves for the global view. + assert!( + resolve_phase_plan_for_workflow_ref_for_actor(Some(temp.path()), Some(STANDARD_WORKFLOW_REF), None).is_ok(), + "the shared workflow must still resolve globally" + ); + } } diff --git a/crates/orchestrator-core/src/workflow/state_manager.rs b/crates/orchestrator-core/src/workflow/state_manager.rs index a64742bc..05ca4fa5 100644 --- a/crates/orchestrator-core/src/workflow/state_manager.rs +++ b/crates/orchestrator-core/src/workflow/state_manager.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; +use animus_actor::Actor; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Duration, Utc}; use rusqlite::{params, params_from_iter, types::Value, Connection, TransactionBehavior}; @@ -216,8 +217,14 @@ impl WorkflowStateManager { fn save_with_conn(&self, conn: &Connection, workflow: &OrchestratorWorkflow) -> Result<()> { let data = compress_json(&serde_json::to_string(workflow)?); let summary = workflow_summary_fields(workflow); + // Upsert (not INSERT OR REPLACE): the `actor` column is written ONCE at + // run bootstrap via `set_workflow_actor` and is NOT part of the protocol + // `OrchestratorWorkflow` record. INSERT OR REPLACE rewrites the whole row + // and would NULL `actor` on the first lifecycle save, dropping the run's + // partition. ON CONFLICT DO UPDATE touches only the listed columns, + // leaving `actor` intact across every later save. conn.execute( - "INSERT OR REPLACE INTO workflows ( + "INSERT INTO workflows ( id, status, task_id, @@ -228,7 +235,16 @@ impl WorkflowStateManager { completed_at, json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + task_id = excluded.task_id, + phase_id = excluded.phase_id, + failed_at = excluded.failed_at, + failure_reason = excluded.failure_reason, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + json = excluded.json", params![ workflow.id, status_str(workflow.status), @@ -253,6 +269,34 @@ impl WorkflowStateManager { Ok(serde_json::from_str(&json)?) } + /// Persist the transport-asserted [`Actor`] this run is bound to, so later + /// lifecycle transitions resolve config from the same partition the run + /// bootstrapped with. `None` clears it (global scope). Called once at run + /// bootstrap; the row MUST already exist (saved first). + /// + /// TRUST BOUNDARY: the actor originates only from the authenticated control + /// request that started the run — never from agent output / YAML / subject. + pub fn set_workflow_actor(&self, workflow_id: &str, actor: Option<&Actor>) -> Result<()> { + let conn = self.open_db()?; + let json = match actor { + Some(actor) => Some(serde_json::to_string(actor).context("serializing actor for persistence")?), + None => None, + }; + conn.execute("UPDATE workflows SET actor = ?2 WHERE id = ?1", params![workflow_id, json]) + .context("failed to persist workflow actor")?; + Ok(()) + } + + /// Load the [`Actor`] a run was bootstrapped with, if any. Best-effort: a + /// missing row / column / malformed value yields `None` (global scope) so a + /// lifecycle op never fails on actor lookup. + pub fn load_workflow_actor(&self, workflow_id: &str) -> Option { + let conn = self.open_db().ok()?; + let json: Option = + conn.query_row("SELECT actor FROM workflows WHERE id = ?1", params![workflow_id], |row| row.get(0)).ok()?; + json.and_then(|raw| serde_json::from_str(&raw).ok()) + } + pub fn list(&self) -> Result> { let conn = self.open_db()?; let mut stmt = conn.prepare("SELECT json FROM workflows WHERE status IN ('running', 'paused')")?; @@ -910,6 +954,7 @@ pub fn open_project_db(project_root: &std::path::Path) -> Result { failure_reason TEXT, started_at TEXT, completed_at TEXT, + actor TEXT, json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_wf_status ON workflows(status); @@ -986,6 +1031,15 @@ fn ensure_workflow_summary_columns(project_root: &std::path::Path, conn: &Connec conn.execute("ALTER TABLE workflows ADD COLUMN completed_at TEXT", []) .context("failed to add workflows.completed_at column")?; } + if !columns.contains("actor") { + // Per-run, transport-asserted caller identity (JSON-encoded `Actor`), + // so lifecycle transitions (resume/complete/cancel/...) re-resolve the + // phase plan + config from the SAME actor partition the run bootstrapped + // with. A column (not the protocol `OrchestratorWorkflow` JSON) because + // the actor is kernel routing context, not part of the wire record. + conn.execute("ALTER TABLE workflows ADD COLUMN actor TEXT", []) + .context("failed to add workflows.actor column")?; + } conn.execute("CREATE INDEX IF NOT EXISTS idx_wf_failed_at ON workflows(status, failed_at)", []) .context("failed to create idx_wf_failed_at")?; diff --git a/crates/orchestrator-core/src/workflow_events.rs b/crates/orchestrator-core/src/workflow_events.rs index 5321c031..3120c41c 100644 --- a/crates/orchestrator-core/src/workflow_events.rs +++ b/crates/orchestrator-core/src/workflow_events.rs @@ -299,7 +299,11 @@ mod tests { } async fn start_workflow_for_task(hub: &Arc, task_id: &str) -> String { - hub.workflows().run(WorkflowRunInput::for_task(task_id.to_string(), None)).await.expect("bootstrap workflow").id + hub.workflows() + .run(WorkflowRunInput::for_task(task_id.to_string(), None), None) + .await + .expect("bootstrap workflow") + .id } #[tokio::test] From 1fdf5c7dcca1c4e6c973ab83afa5e619bfad9b53 Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Mon, 29 Jun 2026 11:53:07 -0600 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v0.6.20=20=E2=80=94=20per?= =?UTF-8?q?-user=20actor=20threading=20+=20scoped=20config=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- crates/orchestrator-cli/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52911f64..9c341c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2763,7 +2763,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchestrator-cli" -version = "0.6.19" +version = "0.6.20" dependencies = [ "animus-actor", "animus-control-protocol", diff --git a/crates/orchestrator-cli/Cargo.toml b/crates/orchestrator-cli/Cargo.toml index d4ecccbc..8f6cd163 100644 --- a/crates/orchestrator-cli/Cargo.toml +++ b/crates/orchestrator-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orchestrator-cli" -version = "0.6.19" +version = "0.6.20" edition = "2021" license = "Elastic-2.0" default-run = "animus"