diff --git a/src/sdk/src/core_host/hooks.rs b/src/sdk/src/core_host/hooks.rs index a298a10c8..63488e9bd 100644 --- a/src/sdk/src/core_host/hooks.rs +++ b/src/sdk/src/core_host/hooks.rs @@ -225,16 +225,20 @@ pub(super) async fn run_command( let mut child = process.spawn()?; let timeout = Duration::from_secs(spec.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT_SECS)); let waited = tokio::time::timeout(timeout, async { - if let Some(mut stdin) = child.stdin.take() { + let write_result = if let Some(mut stdin) = child.stdin.take() { use tokio::io::AsyncWriteExt; - stdin.write_all(payload).await?; - } - child.wait().await + stdin.write_all(payload).await + } else { + Ok(()) + }; + let status = child.wait().await?; + Ok::<_, std::io::Error>((status, write_result)) }) .await; match waited { - Ok(Ok(status)) => { + Ok(Ok((status, write_result))) => { if enforce_status { + write_result?; anyhow::ensure!(status.success(), "hook command exited with {status}"); } Ok(()) diff --git a/src/sdk/src/core_host/hooks_tests.rs b/src/sdk/src/core_host/hooks_tests.rs index 6731f3a34..24a5219c4 100644 --- a/src/sdk/src/core_host/hooks_tests.rs +++ b/src/sdk/src/core_host/hooks_tests.rs @@ -235,3 +235,45 @@ async fn a_timed_out_stop_hook_is_killed_without_failing_the_turn() { "the stop hook must return after its timeout, not command completion" ); } + +/// One byte on top of the pipe buffer: commands that close stdin deterministically +/// hit a broken-pipe write with a payload this big, whichever side of the race +/// the write lands on. +const STDIN_RACE_PAYLOAD: &[u8] = &[b'x'; 1 << 20]; + +#[cfg(unix)] +#[tokio::test] +async fn an_enforced_hook_whose_stdin_write_fails_vetoes() { + // The command closes its stdin immediately and stays alive briefly, so the + // payload cannot fully arrive. An enforced (pre-hook) run must surface the + // write failure: the hook never received its input, so approving the tool + // call as if it had would run it unvetted. + let err = run_command( + &spec("exec 0<&-; sleep 0.2"), + STDIN_RACE_PAYLOAD, + true, + &Default::default(), + ) + .await + .expect_err("an enforced hook whose stdin write fails must veto"); + assert!( + err.to_string().contains("pipe"), + "expected a pipe error, got: {err}" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_stop_hook_whose_stdin_write_fails_is_observational() { + // The race this guards: a stop hook may close its stdin — or exit — before + // the payload is written. The hook observes, so that write failure must not + // fail the turn; swallowing it is exactly the non-enforced path's job. + run_command( + &spec("exec 0<&-; sleep 0.2"), + STDIN_RACE_PAYLOAD, + false, + &Default::default(), + ) + .await + .expect("a stop hook whose stdin write fails is observational"); +} diff --git a/src/sdk/src/core_host/shared.rs b/src/sdk/src/core_host/shared.rs index a49068d6d..f16b50add 100644 --- a/src/sdk/src/core_host/shared.rs +++ b/src/sdk/src/core_host/shared.rs @@ -68,6 +68,17 @@ pub fn install(core: Arc) -> bool { /// every later caller — see the module docs on why a failed boot is not /// retried. pub async fn shared() -> Result, String> { + shared_with_hooks(&crate::harness_hooks::HooksConfig::default()).await +} + +/// This process's core, booting one with `hooks` if nobody has installed any. +/// +/// The hooks are supplied by the dispatcher rather than read from ambient +/// process state so headless workflow runs preserve the same configured hook +/// policy as a TUI-hosted core. +pub async fn shared_with_hooks( + hooks: &crate::harness_hooks::HooksConfig, +) -> Result, String> { SHARED .get_or_init(|| async { // A host that never bound the core — a workflow run, an MCP @@ -83,7 +94,7 @@ pub async fn shared() -> Result, String> { let env: std::collections::HashMap = std::env::vars().collect(); let home = crate::home::medulla_home(&env); super::bind_workspace(&env, &home); - super::boot() + super::boot_with_hooks(hooks) .await .map(Arc::new) .map_err(|err| format!("could not start the embedded OpenHuman core: {err}")) diff --git a/src/sdk/src/daemon/capabilities/mod.rs b/src/sdk/src/daemon/capabilities/mod.rs index c55304057..b2328ca58 100644 --- a/src/sdk/src/daemon/capabilities/mod.rs +++ b/src/sdk/src/daemon/capabilities/mod.rs @@ -94,6 +94,7 @@ pub async fn probe_capabilities(options: ProbeOptions) -> AgentCapabilities { None => CAPABILITY_PROMPT.to_string(), }; let run_options = RunTaskOptions { + origin: crate::daemon::providers::RunTaskOrigin::CapabilityProbe, // Unattributed by design: the probe asks this machine about itself, on // no peer's behalf, so it must not join any conversation. conversation: String::new(), diff --git a/src/sdk/src/daemon/embedded/mod.rs b/src/sdk/src/daemon/embedded/mod.rs index 3f1bcaa82..9afe7d555 100644 --- a/src/sdk/src/daemon/embedded/mod.rs +++ b/src/sdk/src/daemon/embedded/mod.rs @@ -330,7 +330,12 @@ async fn drain_loop( stats.received += 1; } let frame = decode_task_frame(&message.text); - runtime.handle_message(message.from, message.text, frame); + // The bridge knows whether the sender is this device's own + // in-process peer (the workflow host), which is the receiver's only + // verifiable ground for trusting a frame's `workflowNode` marker; + // see [`DaemonRuntime::handle_message_from`]. + let sender_device_local = bridge.is_device_local(&message.from).await; + runtime.handle_message_from(message.from, message.text, frame, sender_device_local); } } } diff --git a/src/sdk/src/daemon/entry.rs b/src/sdk/src/daemon/entry.rs index 292038fdb..dac304b61 100644 --- a/src/sdk/src/daemon/entry.rs +++ b/src/sdk/src/daemon/entry.rs @@ -375,7 +375,13 @@ pub async fn run_daemon( _ = transport.wait_for_inbox(poll) => { for message in transport.drain_inbox(50).await { let frame = decode_task_frame(&message.text); - runtime.handle_message(message.from, message.text, frame); + // Every peer here is remote — the standalone daemon serves + // only over the host link — so a frame's `workflowNode` + // marker can never buy workflow authority. The transport + // states the verdict rather than hard-coding `false` here so + // a future device-local transport needs no reminder. + let sender_device_local = transport.is_device_local(&message.from).await; + runtime.handle_message_from(message.from, message.text, frame, sender_device_local); } } } @@ -389,7 +395,10 @@ pub async fn run_daemon( async fn drain_once(transport: &LinkBridge, runtime: &DaemonRuntime) { for message in transport.drain_inbox(50).await { let frame = crate::protocol::decode_task_frame(&message.text); - runtime.handle_message(message.from, message.text, frame); + // Like the serve loop above: a host-link peer is never device-local, so + // it can never claim workflow authority through the frame marker. + let sender_device_local = transport.is_device_local(&message.from).await; + runtime.handle_message_from(message.from, message.text, frame, sender_device_local); } } diff --git a/src/sdk/src/daemon/providers/acp/tests/execution.rs b/src/sdk/src/daemon/providers/acp/tests/execution.rs index 439c01156..c89f07a27 100644 --- a/src/sdk/src/daemon/providers/acp/tests/execution.rs +++ b/src/sdk/src/daemon/providers/acp/tests/execution.rs @@ -152,6 +152,7 @@ fn disabling_workflows_keeps_the_fleet_family_on_the_session_grant() { /// A `RunTaskOptions` carrying `attribution`, with everything else inert. fn attribution_options(attribution: bool) -> RunTaskOptions { RunTaskOptions { + origin: crate::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: crate::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index d88cbd1cd..2f7aebd7d 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -18,6 +18,7 @@ use super::fold::FoldState; /// matters to the two functions under test here. fn options(transport: HarnessTransport, env: &[(&str, &str)]) -> RunTaskOptions { RunTaskOptions { + origin: super::super::types::RunTaskOrigin::DelegatedTask, provider: HarnessProvider::Codex, transport, prompt: String::new(), diff --git a/src/sdk/src/daemon/providers/mod.rs b/src/sdk/src/daemon/providers/mod.rs index 8d73bedee..79aebf414 100644 --- a/src/sdk/src/daemon/providers/mod.rs +++ b/src/sdk/src/daemon/providers/mod.rs @@ -33,5 +33,5 @@ pub use execute::{is_transient_lock, run_provider_task, with_auth_hint}; pub use openhuman::{run_openhuman_task, uses_embedded_core}; pub use types::{ Abort, ExistsOnPath, OnEvent, OnStdin, OnWorkspaceContext, RunTaskFn, RunTaskOptions, - RunTaskResult, + RunTaskOrigin, RunTaskResult, }; diff --git a/src/sdk/src/daemon/providers/openhuman/mod.rs b/src/sdk/src/daemon/providers/openhuman/mod.rs index fb02684ec..1943e903d 100644 --- a/src/sdk/src/daemon/providers/openhuman/mod.rs +++ b/src/sdk/src/daemon/providers/openhuman/mod.rs @@ -31,16 +31,27 @@ //! — so a bounded workflow node gets a fresh thread and a resumed conversation //! keeps its own, exactly as the CLI providers' session ids behave. //! -//! # What is deliberately absent +//! [`run::run_openhuman_task`] also tells the core two things about the turn +//! that a CLI provider states with argv and a working directory: who authorized +//! it, and which checkout it works in. Both are scoped around the dispatch — +//! see that function. +//! +//! # Hooks //! -//! *Hooks.* [`crate::harness_hooks`] installs lifecycle hooks onto a child's -//! argv, and there is no child. A turn here is a function call in this process, -//! so the thing hooks exist to observe — a separate program doing work on its -//! own — is not happening. Reported once at dispatch rather than left for an -//! operator to infer from an empty hook log. +//! Present, and not through argv. [`crate::harness_hooks`] installs lifecycle +//! hooks onto a *child's* command line, and there is no child here — so +//! [`crate::core_host::hooks`] registers the operator's `PreToolUse`, +//! `PostToolUse`, and `Stop` hooks directly on the core at boot instead. They +//! are process-global rather than per-dispatch, which is the one difference an +//! operator sees: a hook declared for OpenHuman fires for every turn this +//! process runs, not only for the ones a workflow node dispatched. +//! +//! # What is deliberately absent //! -//! *Managed skills and MCP tools.* Same reason, and in this case no loss: the -//! core reaches Medulla through the process it is already inside. +//! *Managed skills and MCP tools.* [`crate::harness_hooks`]'s remaining job is +//! to hand a child process a skills directory and an MCP config, and neither +//! survives having no child. In this case no loss: the core reaches Medulla +//! through the process it is already inside. //! *A model of its own.* The turn runs on whatever the operator chose — see //! [`model`] for every route to that choice and the order they resolve in. diff --git a/src/sdk/src/daemon/providers/openhuman/run.rs b/src/sdk/src/daemon/providers/openhuman/run.rs index bba3c374c..6ab18bcb5 100644 --- a/src/sdk/src/daemon/providers/openhuman/run.rs +++ b/src/sdk/src/daemon/providers/openhuman/run.rs @@ -1,12 +1,21 @@ //! Executing one task as an in-process OpenHuman agent turn. +//! +//! Two things beyond the prompt make that turn able to do real work, and both +//! are scoped around the dispatch rather than passed as parameters — see +//! [`run_openhuman_task`] and the task-locals it enters. +use std::path::PathBuf; use std::time::Duration; +use openhuman_core::openhuman::agent::turn_origin::{ + with_origin, AgentTurnOrigin, TrustedAutomationSource, +}; +use openhuman_core::openhuman::agent::turn_workspace::with_workspace; use serde_json::{json, Value}; use crate::protocol::{HarnessEvent, HarnessProvider}; -use super::super::types::{RunTaskOptions, RunTaskResult}; +use super::super::types::{RunTaskOptions, RunTaskOrigin, RunTaskResult}; /// The core method that runs a full agent turn. /// @@ -28,6 +37,25 @@ pub fn uses_embedded_core(options: &RunTaskOptions) -> bool { /// Run one task as an OpenHuman agent turn in this process. /// +/// # What the turn is allowed to do +/// +/// Per-turn state is scoped around the dispatch, and without the OpenHuman +/// task-locals the turn cannot do the work a node asks of it: +/// +/// * **Origin.** OpenHuman's approval gate refuses every external-effect tool +/// (`shell`, `edit`, `apply_patch`, the `*_exec` family) from a call site +/// that carries no [`AgentTurnOrigin`] — the fail-closed default for an +/// unlabelled caller. A workflow node is not unlabelled: the graph that runs +/// it was authored and saved by the operator, so its actions carry the same +/// trust root a user-authored cron job's do. That is exactly +/// [`TrustedAutomationSource::Workflow`], which is what this scopes. +/// * **Workspace.** The run names a checkout ([`RunTaskOptions::cwd`]). Scoping +/// it makes it both the turn's working directory and a read/write root for +/// the path policy, so a write into that tree is not refused as an escape +/// from the core's own `workspace_dir`. See +/// [`openhuman_core::openhuman::agent::turn_workspace`] on why the grant is +/// no stronger than a configured trusted root. +/// /// # Errors /// /// Returns a sentence when the core cannot be started, when the turn is @@ -37,6 +65,7 @@ pub fn uses_embedded_core(options: &RunTaskOptions) -> bool { pub async fn run_openhuman_task(options: RunTaskOptions) -> Result { let RunTaskOptions { prompt, + origin, cwd, model, env, @@ -53,23 +82,14 @@ pub async fn run_openhuman_task(options: RunTaskOptions) -> Result 0 { - tracing::warn!( - hooks = configured, - "medulla hooks are not installed for OpenHuman: the turn runs in this process, \ - so there is no child harness for a lifecycle hook to wrap", - ); - } - if abort.is_aborted() { return Err("openhuman task aborted before start".to_string()); } - let core = crate::core_host::shared::shared().await?; + // A headless workflow may be the first OpenHuman caller in this process. + // Its hooks must reach that lazy boot; an already installed TUI core is + // retained by `shared_with_hooks` and already owns its hook registration. + let core = crate::core_host::shared::shared_with_hooks(&hooks).await?; // The core's own continuity key. A bounded workflow node arrives with no // resume id and gets a fresh thread — which is the isolation a node needs, @@ -94,16 +114,17 @@ pub async fn run_openhuman_task(options: RunTaskOptions) -> Result Result(origin: RunTaskOrigin, thread_id: &str, future: F) -> F::Output +where + F: std::future::Future, +{ + if origin == RunTaskOrigin::Workflow { + with_origin( + AgentTurnOrigin::TrustedAutomation { + // The turn's own id, so an audit row or a parked approval names + // the dispatch it came from rather than a constant. + job_id: thread_id.to_string(), + source: TrustedAutomationSource::Workflow { + // The node already ran because the operator's graph said it + // should; parking each tool call for a second decision would + // strand an unattended run on a prompt nobody is watching. + require_approval: false, + }, + }, + future, + ) + .await + } else { + future.await + } +} + +/// Run `fut` with the run's checkout scoped as the turn's workspace. +/// +/// A no-op when `cwd` does not resolve to a directory — see +/// [`turn_workspace_root`]. Written as a wrapper rather than an `if` at the +/// call site because the two arms have different types: entering a task-local +/// scope changes the future, and only a function can hide that. +async fn scoped_workspace(cwd: &str, fut: F) -> F::Output { + match turn_workspace_root(cwd) { + Some(root) => with_workspace(root, fut).await, + None => fut.await, + } +} + +/// The absolute directory `cwd` names, when it names one. +/// +/// Returns `None` for the empty string and for anything that is not a +/// directory on this machine. Both are ordinary rather than exceptional: a +/// dispatch that never set a working directory arrives with `"."` or `""`, and +/// a stale path is a host's mistake that should leave the turn on the core's +/// own workspace rather than granting a root that does not exist. +/// +/// Canonicalized because the grant is a `starts_with` containment check on the +/// paths the tools resolve: a symlinked or `..`-laden root would fail to +/// contain its own contents and quietly refuse every write into it. +pub(super) fn turn_workspace_root(cwd: &str) -> Option { + if cwd.is_empty() { + return None; + } + let resolved = std::fs::canonicalize(cwd).ok()?; + if !resolved.is_dir() { + tracing::warn!( + cwd = %resolved.display(), + "openhuman turn: the run's working directory is not a directory — \ + the turn stays on the core's own workspace", + ); + return None; + } + Some(resolved) +} + /// Hand one synthesized event to the caller's callback, when there is one. fn emit(on_event: &mut Option, kind: &str, payload: Value) { let Some(callback) = on_event.as_mut() else { diff --git a/src/sdk/src/daemon/providers/openhuman/tests.rs b/src/sdk/src/daemon/providers/openhuman/tests.rs index 7041c6bf7..6d3e89a33 100644 --- a/src/sdk/src/daemon/providers/openhuman/tests.rs +++ b/src/sdk/src/daemon/providers/openhuman/tests.rs @@ -13,11 +13,12 @@ use crate::protocol::{HarnessProvider, HarnessTransport}; use crate::sessions::SessionClass; use super::super::types::{Abort, RunTaskOptions}; -use super::run::{reply_text, uses_embedded_core}; +use super::run::{reply_text, turn_workspace_root, uses_embedded_core}; /// Options naming `provider`, with everything else at its least interesting. fn options(provider: HarnessProvider) -> RunTaskOptions { RunTaskOptions { + origin: super::super::types::RunTaskOrigin::DelegatedTask, provider, transport: HarnessTransport::Cli, prompt: "do the thing".to_string(), @@ -103,6 +104,62 @@ async fn an_aborted_task_fails_without_booting_a_core() { assert!(error.contains("aborted before start"), "{error}"); } +/// A dispatch that named no working directory grants no root: the turn stays on +/// the core's own workspace rather than on whatever the process happened to be +/// standing in. +#[test] +fn an_unset_working_directory_grants_no_root() { + assert!(turn_workspace_root("").is_none()); + assert!(turn_workspace_root(" ").is_none()); +} + +/// A path that does not exist is a host's mistake, not a reason to fail the +/// turn — and granting a root nothing resolves under would be meaningless. +#[test] +fn a_missing_working_directory_grants_no_root() { + let missing = std::env::temp_dir().join("medulla-openhuman-not-here-6f1a2b"); + assert!(turn_workspace_root(missing.to_str().unwrap()).is_none()); +} + +/// A file is not a workspace. Caught here rather than left to fail once per +/// tool call inside the turn. +#[test] +fn a_file_is_not_a_workspace_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("not-a-dir"); + std::fs::write(&file, b"x").expect("write"); + assert!(turn_workspace_root(file.to_str().unwrap()).is_none()); +} + +/// The run's checkout resolves to an absolute, canonical path — the form the +/// core's containment check compares against, so a symlinked or `..`-laden +/// path still contains the files written under it. +#[test] +fn the_runs_checkout_resolves_to_a_canonical_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let nested = dir.path().join("checkout"); + std::fs::create_dir(&nested).expect("mkdir"); + let indirect = nested.join("..").join("checkout"); + let resolved = turn_workspace_root(indirect.to_str().unwrap()).expect("an existing directory"); + assert!(resolved.is_absolute()); + assert_eq!(resolved, nested.canonicalize().expect("canonical")); +} + +/// Spaces are valid path characters, including at either end of a checkout's +/// name; the workspace grant must preserve them rather than treating `cwd` as +/// user-facing prose. +#[test] +fn a_working_directory_with_edge_whitespace_is_preserved() { + let dir = tempfile::tempdir().expect("tempdir"); + let checkout = dir.path().join(" checkout "); + std::fs::create_dir(&checkout).expect("mkdir"); + + assert_eq!( + turn_workspace_root(checkout.to_str().expect("utf-8 path")), + Some(checkout.canonicalize().expect("canonical")), + ); +} + /// With nothing in the environment the turn asks for whatever the dispatch /// already resolved — the node's model, the preset's, or the host default, /// which by this point are one value. diff --git a/src/sdk/src/daemon/providers/tests.rs b/src/sdk/src/daemon/providers/tests.rs index 5ec34e5ad..4f4a4bf43 100644 --- a/src/sdk/src/daemon/providers/tests.rs +++ b/src/sdk/src/daemon/providers/tests.rs @@ -33,6 +33,7 @@ async fn direct_runs_report_the_session_before_workspace_context() { std::fs::set_permissions(&harness, std::fs::Permissions::from_mode(0o755)).unwrap(); let order = Arc::new(Mutex::new(Vec::new())); let options = RunTaskOptions { + origin: super::types::RunTaskOrigin::DelegatedTask, transport: Default::default(), conversation: "peer".into(), session_class: crate::sessions::SessionClass::Unbound, @@ -422,6 +423,8 @@ fn idle_probe_options( std::fs::write(&harness, body).unwrap(); std::fs::set_permissions(&harness, std::fs::Permissions::from_mode(0o755)).unwrap(); let options = RunTaskOptions { + // A watchdog test drives a delegated task, exactly like a peer would. + origin: super::types::RunTaskOrigin::DelegatedTask, transport: Default::default(), conversation: "peer".into(), session_class: crate::sessions::SessionClass::Unbound, diff --git a/src/sdk/src/daemon/providers/types.rs b/src/sdk/src/daemon/providers/types.rs index 2e7be7874..3a5cf24af 100644 --- a/src/sdk/src/daemon/providers/types.rs +++ b/src/sdk/src/daemon/providers/types.rs @@ -93,6 +93,12 @@ impl Abort { pub struct RunTaskOptions { /// The coding-agent CLI to spawn. pub provider: HarnessProvider, + /// The dispatch path that requested this run. + /// + /// Providers normally treat this as observability context. The embedded + /// OpenHuman adapter uses it as a security boundary: only an authored + /// workflow node may receive unattended automation trust. + pub origin: RunTaskOrigin, /// The flavor of `provider` this run uses. /// /// [`Cli`](HarnessTransport::Cli) — the default — forks the provider's @@ -183,6 +189,21 @@ pub struct RunTaskOptions { pub on_workspace_context: Option, } +/// The Medulla entry point that dispatched a provider task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunTaskOrigin { + /// A node in an operator-authored workflow graph. + Workflow, + /// An authenticated peer's discrete delegated task. + DelegatedTask, + /// A conversational message from an authenticated peer. + Conversation, + /// An interactive local session turn. + Interactive, + /// Medulla's own provider-capability probe. + CapabilityProbe, +} + /// The outcome of a headless run. #[derive(Debug, Clone)] pub struct RunTaskResult { diff --git a/src/sdk/src/daemon/runtime.rs b/src/sdk/src/daemon/runtime.rs index 3e11fa027..df83179bf 100644 --- a/src/sdk/src/daemon/runtime.rs +++ b/src/sdk/src/daemon/runtime.rs @@ -113,7 +113,33 @@ impl DaemonRuntime { /// Fire-and-forget dispatch of one inbound message. Never panics to the /// caller; the work runs on a spawned task tracked by [`DaemonRuntime::idle`]. + /// + /// The sender is treated as remote, so a frame's `workflow_node` marker + /// cannot purchase workflow authority on this call: only + /// [`handle_message_from`](Self::handle_message_from), which knows the + /// sender is on this device's own loopback, may honour it. pub fn handle_message(&self, from: String, text: String, frame: Option) { + self.handle_message_from(from, text, frame, false) + } + + /// Fire-and-forget dispatch of one inbound message, with the sender's + /// device-locality supplied by the transport that delivered it. + /// + /// `sender_device_local` is the receiver's own answer to "is `from` served + /// on this device", never anything the frame declares. It is the one thing + /// that lets a plain task frame carry + /// [`RunTaskOrigin::Workflow`](crate::daemon::providers::RunTaskOrigin::Workflow): + /// the local workflow host dispatches its `agent` nodes over an in-process + /// loopback bridge, while a remote authenticated peer can simply write the + /// marker into its own JSON — so the daemon must distinguish the two, and + /// device-locality is the only receiver-verifiable property that does. + pub fn handle_message_from( + &self, + from: String, + text: String, + frame: Option, + sender_device_local: bool, + ) { // A screen message is never a prompt. The plain-text path types whatever // it is given into a harness, so a `medulla.screen.v1` body reaching it // is executed rather than ignored — a subscribe arriving as an @@ -130,7 +156,7 @@ impl DaemonRuntime { let this = self.clone(); tokio::spawn(async move { match frame { - Some(frame) => this.handle_frame(from, frame).await, + Some(frame) => this.handle_frame(from, frame, sender_device_local).await, None => this.handle_plain_text(from, text).await, } if this.inner.inflight_count.fetch_sub(1, Ordering::SeqCst) == 1 { diff --git a/src/sdk/src/daemon/task_loop/mod.rs b/src/sdk/src/daemon/task_loop/mod.rs index cbfb54618..237c3ef80 100644 --- a/src/sdk/src/daemon/task_loop/mod.rs +++ b/src/sdk/src/daemon/task_loop/mod.rs @@ -23,7 +23,19 @@ use super::types::DaemonRuntime; impl DaemonRuntime { /// Route a decoded task frame to its handler; responses are ignored. - pub(super) async fn handle_frame(&self, from: String, frame: TaskFrame) { + /// + /// `sender_device_local` is the receiver's own verdict on `from`, carried in + /// from the transport that delivered the frame (see + /// [`handle_message_from`](crate::daemon::DaemonRuntime::handle_message_from)). + /// It alone may let a plain task frame's `workflow_node` marker buy + /// [`RunTaskOrigin::Workflow`](crate::daemon::providers::RunTaskOrigin::Workflow); + /// [`run::handle_task`](Self::handle_task) is where that decision is gated. + pub(super) async fn handle_frame( + &self, + from: String, + frame: TaskFrame, + sender_device_local: bool, + ) { match frame.kind { // A frame naming a workflow runs that saved graph instead of // handing its text to a harness as an instruction. @@ -51,7 +63,7 @@ impl DaemonRuntime { ) .await } - TaskFrameKind::Task => self.handle_task(from, frame).await, + TaskFrameKind::Task => self.handle_task(from, frame, sender_device_local).await, TaskFrameKind::Input => self.handle_input(from, frame).await, TaskFrameKind::Abort => self.handle_abort(from, frame).await, TaskFrameKind::Capabilities => self.handle_capabilities(from, frame).await, diff --git a/src/sdk/src/daemon/task_loop/run.rs b/src/sdk/src/daemon/task_loop/run.rs index f510e6731..48ad01228 100644 --- a/src/sdk/src/daemon/task_loop/run.rs +++ b/src/sdk/src/daemon/task_loop/run.rs @@ -31,7 +31,19 @@ const HEARTBEAT_STATUS: &str = "still working"; impl DaemonRuntime { /// Admit, execute, and reply to a `task` frame, forwarding throttled status. - pub(super) async fn handle_task(&self, from: String, frame: TaskFrame) { + /// + /// `sender_device_local` is the receiver's own verdict on `from`, carried in + /// from the transport (see + /// [`handle_message_from`](crate::daemon::DaemonRuntime::handle_message_from)). + /// It is the only thing that may let the frame's `workflow_node` marker + /// grant [`RunTaskOrigin::Workflow`] — the marker alone is caller-controlled + /// JSON and cannot be trusted from a remote peer. + pub(super) async fn handle_task( + &self, + from: String, + frame: TaskFrame, + sender_device_local: bool, + ) { let correlation = frame.correlation_id.clone(); let custom_harness = match frame.custom_harness.as_deref() { Some(id) => match self @@ -433,6 +445,20 @@ impl DaemonRuntime { }; let options = RunTaskOptions { + // `workflowNode` is trusted only from a device-local sender. The + // workflow plane is a daemon-local dispatch: the workflow host runs + // each `agent` node over an in-process loopback bridge, so the only + // frames that legitimately carry the marker arrive from a peer this + // daemon itself serves. A remote authenticated peer can write the + // key into its own JSON, so there it must be read as ordinary + // delegated work — otherwise the marker would let any such peer + // mint `RunTaskOrigin::Workflow`, which is what suppresses the + // embedded harness's approval prompts. + origin: if sender_device_local && frame.workflow_node { + crate::daemon::providers::RunTaskOrigin::Workflow + } else { + crate::daemon::providers::RunTaskOrigin::DelegatedTask + }, // The *authenticated* sender, never anything from the frame body: a // frame cannot be trusted to name its own author. This says *whose* // the run is; `session_class` separately says whether it may share @@ -687,6 +713,7 @@ impl DaemonRuntime { .expect("semaphore is never closed"); self.log(&format!("plaintext DM → {}", provider.as_str())); let options = RunTaskOptions { + origin: crate::daemon::providers::RunTaskOrigin::Conversation, conversation: from.clone(), // A conversational message continues the sender's session — that is // what makes a DM a conversation rather than a series of unrelated diff --git a/src/sdk/src/daemon/task_loop/workflow/dispatch.rs b/src/sdk/src/daemon/task_loop/workflow/dispatch.rs index e2922dd27..89f645f59 100644 --- a/src/sdk/src/daemon/task_loop/workflow/dispatch.rs +++ b/src/sdk/src/daemon/task_loop/workflow/dispatch.rs @@ -31,6 +31,8 @@ pub(in crate::daemon) struct RuntimeDispatch { /// The authenticated sender the workflow is being run for, so nodes inherit /// the same conversation attribution an ordinary task would get. conversation: String, + /// Why this dispatcher is issuing its harness turn. + origin: crate::daemon::providers::RunTaskOrigin, } impl RuntimeDispatch { @@ -39,9 +41,19 @@ impl RuntimeDispatch { Self { runtime, conversation, + origin: crate::daemon::providers::RunTaskOrigin::Workflow, } } + /// Set the authority source for this dispatcher's turns. + pub(in crate::daemon) fn with_origin( + mut self, + origin: crate::daemon::providers::RunTaskOrigin, + ) -> Self { + self.origin = origin; + self + } + /// The custom harness preset `request` names, resolved against this host. /// /// A workflow node reaches a harness through the same presets an ordinary @@ -194,6 +206,7 @@ impl HarnessDispatch for RuntimeDispatch { )); let options = RunTaskOptions { + origin: self.origin, conversation: self.conversation.clone(), // A workflow node is discrete work, like the task frame that // started the graph — nodes share a conversation for attribution, diff --git a/src/sdk/src/daemon/task_loop/workflow/handle.rs b/src/sdk/src/daemon/task_loop/workflow/handle.rs index 13d22cfd4..caf39281f 100644 --- a/src/sdk/src/daemon/task_loop/workflow/handle.rs +++ b/src/sdk/src/daemon/task_loop/workflow/handle.rs @@ -299,7 +299,12 @@ impl DaemonRuntime { } let session = EvolveSession { store, - dispatch: Arc::new(RuntimeDispatch::new(self.clone(), from.to_string())), + // An evolution pass is restricted to proposal tools. It is not an + // authored workflow node and must not inherit workflow authority. + dispatch: Arc::new( + RuntimeDispatch::new(self.clone(), from.to_string()) + .with_origin(crate::daemon::providers::RunTaskOrigin::DelegatedTask), + ), worker_address: self.inner.config.default_provider.as_str().to_string(), provider: Some(self.inner.config.default_provider), model: self.inner.config.model.clone(), diff --git a/src/sdk/src/daemon/tests/mod.rs b/src/sdk/src/daemon/tests/mod.rs index 39eab874e..34c77116e 100644 --- a/src/sdk/src/daemon/tests/mod.rs +++ b/src/sdk/src/daemon/tests/mod.rs @@ -25,6 +25,7 @@ use super::*; mod admission_tests; mod capability_tests; mod custom_harness_tests; +mod origin_tests; mod provider_tests; mod system_info_tests; mod task_attribution_tests; @@ -100,6 +101,7 @@ pub(super) fn task_frame(task_id: &str, text: &str, correlation: Option<&str>) - custom_harness: None, model: None, tool_mode: None, + workflow_node: false, workflow: None, workflow_fingerprint: None, workflow_inputs: Default::default(), diff --git a/src/sdk/src/daemon/tests/origin_tests.rs b/src/sdk/src/daemon/tests/origin_tests.rs new file mode 100644 index 000000000..c37993d31 --- /dev/null +++ b/src/sdk/src/daemon/tests/origin_tests.rs @@ -0,0 +1,111 @@ +//! Workflow-origin gating: a task frame's `workflowNode` marker may buy +//! [`RunTaskOrigin::Workflow`] only from a device-local sender. +//! +//! The marker is caller-controlled JSON, so a remote authenticated peer could +//! simply set it — and `Workflow` is the origin that suppresses the embedded +//! harness's approval prompts. The only legitimate producers are daemon-local +//! dispatch loops (the workflow host running `agent` nodes over an in-process +//! loopback bridge), so the runtime gates the promotion on the receiver's own +//! verdict of where the sender lives. These tests pin both halves of that +//! boundary: honest loopback authority is preserved, and a forged marker from a +//! remote peer is read as ordinary delegated work. + +use std::sync::{Arc, Mutex as StdMutex}; + +use crate::daemon::providers::{RunTaskFn, RunTaskOptions, RunTaskOrigin, RunTaskResult}; +use crate::daemon::DaemonRuntime; +use crate::protocol::{TaskFrame, TaskFrameKind}; + +use super::{base_config, decoded_frames, recording_send, task_frame}; + +/// A runner that records the origin of every run it is given. +fn origin_runner(seen: Arc>>) -> RunTaskFn { + Arc::new(move |opts: RunTaskOptions| { + seen.lock().unwrap().push(opts.origin); + Box::pin(async move { + Ok(RunTaskResult { + session_id: None, + usage: None, + provider: opts.provider, + reply: "done".to_string(), + events: 0, + }) + }) + }) +} + +/// A `Task` frame with the `workflowNode` marker set, as the workflow host's +/// loopback dispatch (and any forger) would send it. +fn workflow_node_frame(task_id: &str, text: &str) -> TaskFrame { + TaskFrame { + workflow_node: true, + ..task_frame(task_id, text, None) + } +} + +#[tokio::test] +async fn forged_workflow_node_from_remote_peer_is_delegated() { + let seen = Arc::new(StdMutex::new(Vec::new())); + let (send, recorded) = recording_send(); + let runtime = DaemonRuntime::new(base_config(), origin_runner(seen.clone()), send); + + // The plain `handle_message` entry states "remote": it is what a host-link + // drain calls for a peer it does not itself serve. No device-local gate + // anywhere, so the frame's marker must not reach the harness as `Workflow`. + runtime.handle_message( + "www.evil.example".into(), + String::new(), + Some(workflow_node_frame("t1", "take over this host")), + ); + runtime.idle().await; + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[RunTaskOrigin::DelegatedTask] + ); + assert!( + decoded_frames(&recorded) + .iter() + .any(|f| f.kind == TaskFrameKind::Reply && f.task_id == "t1"), + "the demoted task should still run and reply" + ); +} + +#[tokio::test] +async fn device_local_workflow_node_keeps_workflow_origin() { + let seen = Arc::new(StdMutex::new(Vec::new())); + let (send, _recorded) = recording_send(); + let runtime = DaemonRuntime::new(base_config(), origin_runner(seen.clone()), send); + + // The embedded drain (LocalWorkflowHost's loopback) answers `is_device_local` + // with `true` for the in-process orchestrator and forwards that verdict here. + runtime.handle_message_from( + "ident-this-device".into(), + String::new(), + Some(workflow_node_frame("t2", "run node")), + true, + ); + runtime.idle().await; + + assert_eq!(seen.lock().unwrap().as_slice(), &[RunTaskOrigin::Workflow]); +} + +#[tokio::test] +async fn device_local_sender_without_marker_is_still_delegated() { + let seen = Arc::new(StdMutex::new(Vec::new())); + let (send, _recorded) = recording_send(); + let runtime = DaemonRuntime::new(base_config(), origin_runner(seen.clone()), send); + + runtime.handle_message_from( + "ident-this-device".into(), + String::new(), + Some(task_frame("t3", "ordinary work", None)), + true, + ); + runtime.idle().await; + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[RunTaskOrigin::DelegatedTask] + ); +} diff --git a/src/sdk/src/flow_engine/caps/dispatch.rs b/src/sdk/src/flow_engine/caps/dispatch.rs index 8a5fca81b..e3c6db547 100644 --- a/src/sdk/src/flow_engine/caps/dispatch.rs +++ b/src/sdk/src/flow_engine/caps/dispatch.rs @@ -85,7 +85,7 @@ impl HarnessDispatch for TaskRunnerDispatch { // No status channel: a workflow's progress is reported per *node* by the // run observer, and forwarding a harness's token-level chatter here as // well would double-report the same work. - self.runner.run(request, None).await + self.runner.run_workflow_node(request, None).await } async fn dispatch_with_status( @@ -93,7 +93,7 @@ impl HarnessDispatch for TaskRunnerDispatch { request: TaskRequest, status: Option>, ) -> Result { - self.runner.run(request, status).await + self.runner.run_workflow_node(request, status).await } fn abort_in_flight(&self) { diff --git a/src/sdk/src/hub/runner/mod.rs b/src/sdk/src/hub/runner/mod.rs index 281237679..62d89b247 100644 --- a/src/sdk/src/hub/runner/mod.rs +++ b/src/sdk/src/hub/runner/mod.rs @@ -18,7 +18,8 @@ use std::time::Duration; use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use crate::protocol::{ - encode_task_frame, AgentCapabilities, EncodeFrameInput, TaskFrameKind, WorkerSystemInfo, + encode_task_frame, encode_workflow_node_task_frame, AgentCapabilities, EncodeFrameInput, + TaskFrameKind, WorkerSystemInfo, }; use crate::bridge::BridgeLiveness; @@ -339,7 +340,20 @@ impl TaskRunner { req: TaskRequest, status: Option>, ) -> Result { - self.run_inner(req, status, false, None, None).await + self.run_inner(req, status, false, false, None, None).await + } + + /// Run one instruction emitted by a workflow `agent` node. + /// + /// The dedicated entry point, rather than a caller-controlled request + /// field, keeps the workflow authority marker owned by the flow-engine + /// dispatch adapter. + pub async fn run_workflow_node( + &self, + req: TaskRequest, + status: Option>, + ) -> Result { + self.run_inner(req, status, false, true, None, None).await } /// Run a dispatch with the screen-control support negotiated specifically @@ -352,7 +366,7 @@ impl TaskRunner { abort: Option>, visible_task_id: Option, ) -> Result { - self.run_inner(req, status, screen_kill, abort, visible_task_id) + self.run_inner(req, status, screen_kill, false, abort, visible_task_id) .await } @@ -361,6 +375,7 @@ impl TaskRunner { req: TaskRequest, status: Option>, screen_kill: bool, + workflow_node: bool, prepared_abort: Option>, visible_task_id: Option, ) -> Result { @@ -411,7 +426,7 @@ impl TaskRunner { }, ); - let body = encode_task_frame(EncodeFrameInput { + let frame = EncodeFrameInput { kind: TaskFrameKind::Task, task_id: req.task_id.clone(), text: req.instruction.clone(), @@ -428,7 +443,12 @@ impl TaskRunner { workflow_inputs: req.workflow_inputs.clone(), conversation: req.conversation.clone(), fleet_depth: req.fleet_depth, - }); + }; + let body = if workflow_node { + encode_workflow_node_task_frame(frame) + } else { + encode_task_frame(frame) + }; tokio::select! { biased; diff --git a/src/sdk/src/protocol/frames/decode.rs b/src/sdk/src/protocol/frames/decode.rs index a3d24eb3e..027ce7e08 100644 --- a/src/sdk/src/protocol/frames/decode.rs +++ b/src/sdk/src/protocol/frames/decode.rs @@ -43,6 +43,10 @@ pub fn decode_task_frame(body: &str) -> Option { .and_then(|v| v.as_str()) .filter(|s| !s.trim().is_empty()) .map(str::to_string); + let workflow_node = obj + .get("workflowNode") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let usage = obj .get("usage") @@ -153,6 +157,7 @@ pub fn decode_task_frame(body: &str) -> Option { custom_harness, model, tool_mode, + workflow_node, workflow, workflow_fingerprint, workflow_inputs, diff --git a/src/sdk/src/protocol/frames/encode.rs b/src/sdk/src/protocol/frames/encode.rs index 072251bbd..a3bc8a109 100644 --- a/src/sdk/src/protocol/frames/encode.rs +++ b/src/sdk/src/protocol/frames/encode.rs @@ -10,6 +10,17 @@ pub fn encode_task_frame(input: EncodeFrameInput) -> String { build(input, FrameAttachments::default()).encode() } +/// Serialize a task issued by an `agent` node within an authenticated workflow. +/// +/// The marker survives loopback and remote task dispatch, allowing the worker +/// to grant OpenHuman's workflow origin to the node without confusing it with +/// an ordinary delegated task frame. +pub fn encode_workflow_node_task_frame(input: EncodeFrameInput) -> String { + let mut frame = build(input, FrameAttachments::default()); + frame.workflow_node = true; + frame.encode() +} + /// [`encode_task_frame`] with reported token usage (reply frames). pub fn encode_task_frame_with_usage(input: EncodeFrameInput, usage: Option) -> String { build( @@ -72,6 +83,7 @@ fn build(input: EncodeFrameInput, attachments: FrameAttachments) -> TaskFrame { custom_harness: input.custom_harness.map(String::into_boxed_str), model: input.model, tool_mode: input.tool_mode, + workflow_node: false, workflow: input.workflow, workflow_fingerprint: input.workflow_fingerprint, workflow_inputs: input.workflow_inputs, diff --git a/src/sdk/src/protocol/frames/mod.rs b/src/sdk/src/protocol/frames/mod.rs index 4db1645d3..d7b226935 100644 --- a/src/sdk/src/protocol/frames/mod.rs +++ b/src/sdk/src/protocol/frames/mod.rs @@ -24,7 +24,7 @@ mod tests; pub use decode::{decode_task_frame, parse_agent_capabilities}; pub use encode::{ encode_task_frame, encode_task_frame_with_attachments, encode_task_frame_with_usage, - encode_task_frame_with_work, + encode_task_frame_with_work, encode_workflow_node_task_frame, }; pub use types::{ dispatchable_flavors, AgentCapabilities, BudgetSource, BudgetWindow, CustomHarnessAdvert, diff --git a/src/sdk/src/protocol/frames/tests/codec.rs b/src/sdk/src/protocol/frames/tests/codec.rs index 29a5799b7..4d1d47c6c 100644 --- a/src/sdk/src/protocol/frames/tests/codec.rs +++ b/src/sdk/src/protocol/frames/tests/codec.rs @@ -2,9 +2,9 @@ //! round-trips, optional-field handling, and tolerant capabilities parsing. use crate::protocol::{ - decode_task_frame, encode_task_frame, parse_agent_capabilities, BudgetSource, BudgetWindow, - EncodeFrameInput, HarnessBudget, HarnessProvider, HarnessReadiness, HarnessTransport, - TaskFrameKind, MEDULLA_TASK_PROTO, + decode_task_frame, encode_task_frame, encode_workflow_node_task_frame, + parse_agent_capabilities, BudgetSource, BudgetWindow, EncodeFrameInput, HarnessBudget, + HarnessProvider, HarnessReadiness, HarnessTransport, TaskFrameKind, MEDULLA_TASK_PROTO, }; use serde_json::json; @@ -41,6 +41,38 @@ fn encodes_a_minimal_frame() { assert!(value.get("model").is_none()); } +/// A workflow agent instruction is distinct from a frame that starts a saved +/// workflow, but its authority marker must survive the runner/daemon boundary. +#[test] +fn workflow_node_marker_round_trips() { + let body = encode_workflow_node_task_frame(EncodeFrameInput { + transport: None, + kind: TaskFrameKind::Task, + task_id: "wf:run:agent#1".to_string(), + text: "inspect the checkout".to_string(), + ts: "2026-08-08T00:00:00.000Z".to_string(), + correlation_id: None, + harness: None, + provider: Some(HarnessProvider::Openhuman), + custom_harness: None, + model: None, + tool_mode: None, + workflow: None, + workflow_fingerprint: None, + workflow_inputs: Default::default(), + conversation: None, + fleet_depth: 0, + }); + + let value: serde_json::Value = serde_json::from_str(&body).expect("frame JSON"); + assert_eq!(value["workflowNode"], true); + assert!( + decode_task_frame(&body) + .expect("workflow-node frame decodes") + .workflow_node + ); +} + #[test] fn rejects_a_fleet_depth_that_cannot_fit_the_protocol_type() { let body = json!({ diff --git a/src/sdk/src/protocol/frames/types.rs b/src/sdk/src/protocol/frames/types.rs index 3fca570bc..c47ddc9a2 100644 --- a/src/sdk/src/protocol/frames/types.rs +++ b/src/sdk/src/protocol/frames/types.rs @@ -557,6 +557,19 @@ pub struct TaskFrame { /// tools — which is the pre-existing behaviour, not a new hazard. #[serde(skip_serializing_if = "Option::is_none", default)] pub tool_mode: Option, + /// Whether this task was emitted by a workflow's `agent` node. + /// + /// This is distinct from [`workflow`](Self::workflow), which asks the + /// receiving worker to start a whole saved graph. A workflow node instead + /// carries one instruction, but its OpenHuman turn needs the workflow + /// approval origin. Absent on older and ordinary frames, which remain + /// delegated tasks. + #[serde( + rename = "workflowNode", + skip_serializing_if = "std::ops::Not::not", + default + )] + pub workflow_node: bool, /// Inbound-only: the continuity group this task belongs to, when the sender /// wants successive tasks to share one harness session. /// diff --git a/src/sdk/src/protocol/mod.rs b/src/sdk/src/protocol/mod.rs index cdd280557..a736987d6 100644 --- a/src/sdk/src/protocol/mod.rs +++ b/src/sdk/src/protocol/mod.rs @@ -33,11 +33,11 @@ pub use control::{ }; pub use frames::{ decode_task_frame, dispatchable_flavors, encode_task_frame, encode_task_frame_with_attachments, - encode_task_frame_with_usage, encode_task_frame_with_work, parse_agent_capabilities, - AgentCapabilities, BudgetSource, BudgetWindow, CustomHarnessAdvert, EncodeFrameInput, - FrameAttachments, HarnessBudget, HarnessProvider, HarnessReadiness, HarnessTransport, - TaskFrame, TaskFrameKind, TokenUsage, WorkflowAdvert, WorkflowInputAdvert, CODEX_SERVER_FLAVOR, - MEDULLA_TASK_PROTO, + encode_task_frame_with_usage, encode_task_frame_with_work, encode_workflow_node_task_frame, + parse_agent_capabilities, AgentCapabilities, BudgetSource, BudgetWindow, CustomHarnessAdvert, + EncodeFrameInput, FrameAttachments, HarnessBudget, HarnessProvider, HarnessReadiness, + HarnessTransport, TaskFrame, TaskFrameKind, TokenUsage, WorkflowAdvert, WorkflowInputAdvert, + CODEX_SERVER_FLAVOR, MEDULLA_TASK_PROTO, }; pub use screen::{ apply_frame, build_frame, changed_rows, coalesce_runs, encode_screen_message, diff --git a/src/sdk/src/sessions/manager/turns.rs b/src/sdk/src/sessions/manager/turns.rs index e148c5ec6..3dd71e9fe 100644 --- a/src/sdk/src/sessions/manager/turns.rs +++ b/src/sdk/src/sessions/manager/turns.rs @@ -390,6 +390,7 @@ impl SessionManager { ) -> Result { let provider = self.provider_for(request); let options = RunTaskOptions { + origin: crate::daemon::providers::RunTaskOrigin::Interactive, conversation: String::new(), // A one-shot turn owns its process for exactly that turn; continuity // across turns comes from `resume`, not from a retained session. diff --git a/src/sdk/src/sessions/tests/input_tests.rs b/src/sdk/src/sessions/tests/input_tests.rs index 56a3b2caf..33d207196 100644 --- a/src/sdk/src/sessions/tests/input_tests.rs +++ b/src/sdk/src/sessions/tests/input_tests.rs @@ -26,6 +26,7 @@ fn task_frame(kind: TaskFrameKind, task_id: &str, text: &str) -> TaskFrame { usage: None, work: None, tool_mode: None, + workflow_node: false, workflow: None, workflow_fingerprint: None, workflow_inputs: Default::default(), diff --git a/src/sdk/tests/e2e_codex_app_server.rs b/src/sdk/tests/e2e_codex_app_server.rs index ea4b723d8..e0f64895b 100644 --- a/src/sdk/tests/e2e_codex_app_server.rs +++ b/src/sdk/tests/e2e_codex_app_server.rs @@ -43,6 +43,7 @@ fn options( ); ( RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, provider: HarnessProvider::Codex, transport: HarnessTransport::AppServer, prompt: prompt.to_string(), diff --git a/src/sdk/tests/e2e_daemon/helpers.rs b/src/sdk/tests/e2e_daemon/helpers.rs index 402f31858..6872ca370 100644 --- a/src/sdk/tests/e2e_daemon/helpers.rs +++ b/src/sdk/tests/e2e_daemon/helpers.rs @@ -115,6 +115,7 @@ pub fn frame( custom_harness: None, model: None, tool_mode: None, + workflow_node: false, workflow: None, workflow_fingerprint: None, workflow_inputs: Default::default(), diff --git a/src/sdk/tests/e2e_daemon_providers.rs b/src/sdk/tests/e2e_daemon_providers.rs index 49154b68d..8ddd05e60 100644 --- a/src/sdk/tests/e2e_daemon_providers.rs +++ b/src/sdk/tests/e2e_daemon_providers.rs @@ -48,6 +48,7 @@ async fn run( let kinds = Arc::new(Mutex::new(Vec::::new())); let sink = kinds.clone(); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), @@ -172,6 +173,7 @@ async fn spawn_failure_for_missing_binary() { "/nonexistent/definitely-not-here" ); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), @@ -209,6 +211,7 @@ async fn abort_before_start_returns_immediately() { let abort = Abort::new(); abort.abort(); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), @@ -250,6 +253,7 @@ async fn abort_mid_run_kills_child() { abort_bg.abort(); }); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), @@ -291,6 +295,7 @@ async fn stdin_input_reaches_child_and_echoes_in_reply() { Arc::new(Mutex::new(None)); let register = stdin_tx.clone(); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), @@ -348,6 +353,7 @@ async fn stdin_is_immediate_eof_for_batch_cli() { let registered = Arc::new(Mutex::new(false)); let register = registered.clone(); let options = RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), diff --git a/src/sdk/tests/e2e_daemon_router.rs b/src/sdk/tests/e2e_daemon_router.rs index 80dc59cc5..d83fc885b 100644 --- a/src/sdk/tests/e2e_daemon_router.rs +++ b/src/sdk/tests/e2e_daemon_router.rs @@ -42,6 +42,7 @@ fn router_options( ) -> RunTaskOptions { let _ = bin; RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: String::new(), diff --git a/src/tui/src/local_host/tests/dispatch.rs b/src/tui/src/local_host/tests/dispatch.rs index ab68e8a8e..00cbb4b0a 100644 --- a/src/tui/src/local_host/tests/dispatch.rs +++ b/src/tui/src/local_host/tests/dispatch.rs @@ -19,6 +19,7 @@ use super::env_with_only_claude; /// conversation, just enough to reach the executor the dispatcher picks. fn dispatch_options(provider: HarnessProvider, bin_env_key: &str) -> RunTaskOptions { RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), provider, diff --git a/src/tui/src/worker/executor_tests/live.rs b/src/tui/src/worker/executor_tests/live.rs index d80f79b63..b1926e610 100644 --- a/src/tui/src/worker/executor_tests/live.rs +++ b/src/tui/src/worker/executor_tests/live.rs @@ -92,6 +92,7 @@ fn live_options( cwd: &str, ) -> RunTaskOptions { RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: peer.to_string(), diff --git a/src/tui/src/worker/executor_tests/mod.rs b/src/tui/src/worker/executor_tests/mod.rs index 38c416510..5e05bdaf6 100644 --- a/src/tui/src/worker/executor_tests/mod.rs +++ b/src/tui/src/worker/executor_tests/mod.rs @@ -98,6 +98,7 @@ fn options( cwd: &str, ) -> RunTaskOptions { RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), conversation: conversation.to_string(), diff --git a/src/tui/src/worker_loop/mod.rs b/src/tui/src/worker_loop/mod.rs index b7c3a7afe..27fed0ab9 100644 --- a/src/tui/src/worker_loop/mod.rs +++ b/src/tui/src/worker_loop/mod.rs @@ -285,7 +285,12 @@ pub(super) fn spawn_inbox_drain( continue; } let frame = decode_task_frame(&message.text); - runtime.handle_message(message.from, message.text, frame); + // A worker-loop inbox only ever carries host-link traffic, i.e. + // remote peers, so a forged `workflowNode` marker must not buy + // workflow authority here. Ask the transport rather than + // hard-coding `false` so the verdict tracks the link. + let sender_device_local = transport.is_device_local(&message.from).await; + runtime.handle_message_from(message.from, message.text, frame, sender_device_local); } // Returns early when the link's pump delivers, so a subscribe is // acted on at about a round trip rather than up to a poll interval diff --git a/src/tui/tests/e2e_session_takeover.rs b/src/tui/tests/e2e_session_takeover.rs index ef8dec071..468b4eae0 100644 --- a/src/tui/tests/e2e_session_takeover.rs +++ b/src/tui/tests/e2e_session_takeover.rs @@ -106,6 +106,7 @@ done PtySessionExecutor::new(sessions.clone(), env.clone(), cwd.clone()).into_run_task(); let (session_tx, session_rx) = tokio::sync::oneshot::channel(); let run = tokio::spawn((run_task)(RunTaskOptions { + origin: medulla::daemon::providers::RunTaskOrigin::DelegatedTask, hooks: medulla::harness_hooks::HooksConfig::default(), transport: Default::default(), provider: HarnessProvider::Codex, diff --git a/vendor/openhuman b/vendor/openhuman index 475c9be44..f1c71435a 160000 --- a/vendor/openhuman +++ b/vendor/openhuman @@ -1 +1 @@ -Subproject commit 475c9be447b78bd8726092662bf6ff6f9daf0e06 +Subproject commit f1c71435a70da6ffce6a3b5f0a491b22b688e31e