Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
eac1120
chore(vendor): add openhuman dependency
senamakel Aug 8, 2026
9d51205
fix(openhuman): handle missing run id in provider response
senamakel Aug 8, 2026
9a44530
fix(openhuman): handle missing run id in provider response
senamakel Aug 8, 2026
e573413
fix(openhuman): handle missing run id in provider response
senamakel Aug 8, 2026
2993972
fix(openhuman): handle missing run id in provider response
senamakel Aug 8, 2026
b8d5f40
chore(openhuman): update vendored dependency
senamakel Aug 8, 2026
efe6fac
fix(openhuman): handle missing run id in provider response
senamakel Aug 8, 2026
3417914
test(openhuman): cover workspace root resolution edge cases
senamakel Aug 8, 2026
ffa68b7
chore(vendor): point openhuman at the turn-origin/workspace fix
senamakel Aug 8, 2026
726b2b6
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/251
senamakel Aug 8, 2026
c76d22d
fix(openhuman): scope workflow trust and lazy hooks
senamakel Aug 8, 2026
f2f1125
test(openhuman): tag provider test origins
senamakel Aug 8, 2026
33eaf49
fix(hooks): ignore stop hook stdin races
senamakel Aug 8, 2026
ac80195
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/251
senamakel Aug 8, 2026
70da574
fix(openhuman): preserve workflow dispatch authority
senamakel Aug 8, 2026
c93b870
fix(runtime): restore daemon shutdown on dropped runtime
senamakel Aug 9, 2026
70d734e
fix(daemon): handle task loop termination on shutdown signal
senamakel Aug 9, 2026
afb4aa5
chore(daemon): remove unused task loop run module
senamakel Aug 9, 2026
528a7f4
fix(daemon): handle task loop exit on channel close
senamakel Aug 9, 2026
a2351ee
fix(embedded): handle missing daemon binary path gracefully
senamakel Aug 9, 2026
398885f
fix(daemon): handle missing entry field in daemon startup
senamakel Aug 9, 2026
d16cfd2
fix(daemon): restore missing shutdown handling
senamakel Aug 9, 2026
3312694
fix(worker_loop): handle missing file in file watcher
senamakel Aug 9, 2026
a812582
chore(daemon): add origin tests for SDK daemon
senamakel Aug 9, 2026
2a89a0d
fix(daemon): restore origin test coverage
senamakel Aug 9, 2026
f730596
fix(daemon): correct test module path for daemon tests
senamakel Aug 9, 2026
365ee69
fix(origin): restore origin test coverage
senamakel Aug 9, 2026
f7e3fef
fix(origin): restore origin test for missing daemon
senamakel Aug 9, 2026
ef5491a
chore: reformat multi-line function calls and imports
senamakel Aug 9, 2026
cd0b4f2
merge: integrate upstream main (109 commits of drift)
senamakel Aug 9, 2026
aec876b
fix(daemon): restore provider test for missing daemon
senamakel Aug 9, 2026
ed4fac3
chore(core_host): fix typo in hooks test comment
senamakel Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/sdk/src/core_host/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
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))) => {
Comment thread
senamakel marked this conversation as resolved.
if enforce_status {
write_result?;
anyhow::ensure!(status.success(), "hook command exited with {status}");
}
Ok(())
Expand Down
13 changes: 12 additions & 1 deletion src/sdk/src/core_host/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ pub fn install(core: Arc<EmbeddedCore>) -> bool {
/// every later caller — see the module docs on why a failed boot is not
/// retried.
pub async fn shared() -> Result<Arc<EmbeddedCore>, String> {
shared_with_hooks(&crate::harness_hooks::HooksConfig::default()).await
Comment thread
senamakel marked this conversation as resolved.
}

/// 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<Arc<EmbeddedCore>, String> {
SHARED
.get_or_init(|| async {
// A host that never bound the core — a workflow run, an MCP
Expand All @@ -83,7 +94,7 @@ pub async fn shared() -> Result<Arc<EmbeddedCore>, String> {
let env: std::collections::HashMap<String, String> = 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}"))
Expand Down
1 change: 1 addition & 0 deletions src/sdk/src/daemon/capabilities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
7 changes: 6 additions & 1 deletion src/sdk/src/daemon/embedded/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
13 changes: 11 additions & 2 deletions src/sdk/src/daemon/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand All @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/sdk/src/daemon/providers/acp/tests/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/sdk/src/daemon/providers/codex_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/sdk/src/daemon/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
27 changes: 19 additions & 8 deletions src/sdk/src/daemon/providers/openhuman/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
senamakel marked this conversation as resolved.
//! 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.
Expand Down
137 changes: 114 additions & 23 deletions src/sdk/src/daemon/providers/openhuman/run.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -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
Expand All @@ -37,6 +65,7 @@ pub fn uses_embedded_core(options: &RunTaskOptions) -> bool {
pub async fn run_openhuman_task(options: RunTaskOptions) -> Result<RunTaskResult, String> {
let RunTaskOptions {
prompt,
origin,
cwd,
model,
env,
Expand All @@ -53,23 +82,14 @@ pub async fn run_openhuman_task(options: RunTaskOptions) -> Result<RunTaskResult
// resolved; see [`super::model`] for the whole precedence order.
let model = super::effective_model(model, &env);

// Said once, at the top, rather than left for an operator to infer from an
// empty hook log. There is no child process here, so there is no argv for
// `harness_hooks` to install onto and nothing for a hook to observe.
let configured = hooks.for_provider(HarnessProvider::Openhuman).len();
if configured > 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,
Expand All @@ -94,16 +114,17 @@ pub async fn run_openhuman_task(options: RunTaskOptions) -> Result<RunTaskResult
"model_override": model,
"thread_id": thread_id,
});
// Scoped around the whole core call, not just around the hook: the tool
// hooks that read it fire from inside the agent loop this call drives, and a
// task-local is the only per-turn channel to a callback registered once,
// process-globally, at boot. Without it a `PostToolUse` auto-commit hook is
// told the Medulla process's startup directory and checkpoints the wrong
// repository. See `core_host::turn_cwd`.
let cwd = std::path::PathBuf::from(&cwd);
// `AgentChatParams` carries no origin or workspace field; those trust
// decisions instead ride task-locals through the full core dispatch. The
// Medulla-owned cwd scope reaches the process-global lifecycle hooks too,
// so a `PostToolUse` auto-commit targets this run's checkout.
let cwd_path = PathBuf::from(&cwd);
let call = crate::core_host::turn_cwd::with_turn_cwd(
Some(cwd.as_path()),
core.raw().invoke(AGENT_CHAT, params),
Some(cwd_path.as_path()),
scoped_workspace(
&cwd,
scoped_origin(origin, &thread_id, core.raw().invoke(AGENT_CHAT, params)),
),
);

// The same idle ceiling a spawned provider gets, applied to the whole turn
Expand Down Expand Up @@ -157,6 +178,76 @@ pub async fn run_openhuman_task(options: RunTaskOptions) -> Result<RunTaskResult
})
}

/// Run `future` with unattended workflow authority only for workflow nodes.
///
/// Delegated tasks, conversational turns, local sessions, and capability
/// probes intentionally remain unlabelled: OpenHuman then applies its
/// fail-closed approval policy to their external-effect tools.
async fn scoped_origin<F>(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<F: std::future::Future>(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<PathBuf> {
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<super::super::types::OnEvent>, kind: &str, payload: Value) {
let Some(callback) = on_event.as_mut() else {
Expand Down
Loading
Loading