Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
hoisted into a shared module so there is exactly one of them: relative paths
join the workspace, absolute paths must resolve inside it, symlinks are
followed, and a missing path or a non-directory fails the step instead of
falling back to the workspace. A run with no workspace resolves nothing and
passes the value to the harness verbatim, as before.
falling back to the workspace. An expression that resolves to `null` fails the
step too, rather than reading as "no directory declared" and falling through
to the agent definition's own `working_dir` or the harness default. A run with
no workspace resolves nothing and passes the value to the harness verbatim, as
before.

- **`AgentRunner::resolve_workdir` + `caps::WorkdirCheck`** — the seam a harness
uses to answer for its **own** filesystem when a run does declare a workspace.
Deciding whether a directory exists, what it canonicalizes to, and whether it
is a directory is an outside-world effect, and on a harness whose agents run
in a container or a remote sandbox the answer is not on the engine's disk.
The shape of the value — absolute vs relative, `..` traversal — is string
arithmetic and stays with the engine, so a host cannot weaken the containment
check that matters most. The method has a default returning
`WorkdirCheck::Unmanaged`, which checks the engine's own filesystem exactly as
before, so every existing `AgentRunner` keeps compiling and behaving
identically.

### Changed

Expand Down
2 changes: 1 addition & 1 deletion src/caps/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ impl AgentRunOutcome {
}

mod runner;
pub use runner::AgentRunner;
pub use runner::{AgentRunner, WorkdirCheck};

#[cfg(test)]
#[path = "agent_tests.rs"]
Expand Down
45 changes: 45 additions & 0 deletions src/caps/agent/runner.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
use super::*;

/// A host's answer to "where does this declared working directory actually
/// live?" — see [`AgentRunner::resolve_workdir`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkdirCheck {
/// Not this harness's filesystem to judge. The engine falls back to
/// checking the directory on its own process filesystem, which is the
/// behavior every host had before this method existed.
Unmanaged,
/// The directory exists in that workspace, and this is its canonical path
/// on the filesystem the agent will actually run on. The engine passes it
/// to the harness verbatim.
Resolved(String),
/// The directory is refused, for this reason. The engine fails the step
/// with the message, prefixed with the node it came from.
Refused(String),
}

/// Runs a host-registered, multi-turn **agent** — the harness seam.
///
/// Where [`LlmProvider::complete`](crate::caps::LlmProvider::complete) is a
Expand Down Expand Up @@ -181,4 +198,32 @@ pub trait AgentRunner: Send + Sync {
.map(|grant| ToolDescriptor::from_grant(grant, conn))
.collect())
}

/// Resolves a node's declared working directory (`config.cwd`, a
/// `sub_workflow` node's `config.workspace`) against `workspace`, on the
/// filesystem the agent will actually run on.
///
/// The engine has no filesystem of its own. It can, and does, check the
/// *shape* of a declared directory — an absolute path, a `..` traversal —
/// because that is string arithmetic. Deciding whether the path **exists**,
/// what it canonicalizes to, and whether it is a directory is an
/// outside-world effect, and on a harness whose agents run in a remote
/// sandbox or a container the answer is not on the engine's disk at all.
/// This method is where such a host answers for its own filesystem.
///
/// `workspace` is the run's declared boundary and `declared` the raw value
/// the author wrote (already expression-resolved). A host that resolves the
/// pair must also **contain** it: returning
/// [`Resolved`](WorkdirCheck::Resolved) asserts the path is inside
/// `workspace`, since only the host can compare two paths on its own
/// filesystem.
///
/// The default returns [`Unmanaged`](WorkdirCheck::Unmanaged) for
/// everything, so the engine canonicalizes locally exactly as it did before
/// this method existed. A host whose agents share the engine's filesystem
/// wants precisely that and should leave it alone.
async fn resolve_workdir(&self, workspace: &str, declared: &str) -> WorkdirCheck {
let _ = (workspace, declared);
WorkdirCheck::Unmanaged
}
}
2 changes: 1 addition & 1 deletion src/caps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::error::Result;

pub use self::agent::{
AgentInput, AgentModelSelection, AgentRunIdentity, AgentRunOutcome, AgentRunRequest,
AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor,
AgentRunner, AgentUsage, ContextBlock, StopReason, ToolDescriptor, WorkdirCheck,
};
pub use self::approval::{
ApprovalDecision, ApprovalOutcome, ApprovalProvider, ApprovalRequest, ApprovalSubject,
Expand Down
2 changes: 1 addition & 1 deletion src/nodes/integration/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ async fn run_turn_indexed(
} else {
"working_dir"
};
let resolved = super::agent_request::resolve_working_dir(ctx, &raw, key)?;
let resolved = super::agent_request::resolve_working_dir(ctx, &raw, key).await?;
if let Value::Object(map) = &mut request {
map.insert("cwd".to_string(), Value::from(resolved.clone()));
map.insert("working_dir".to_string(), Value::from(resolved));
Expand Down
33 changes: 27 additions & 6 deletions src/nodes/integration/agent_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,29 @@ fn narrow_tools(granted: &[ToolGrant], requested: &[ToolGrant], node_id: &str) -
/// both are set.
///
/// # Errors
/// Refuses a non-string or a blank value, exactly as a `shell` node's `cwd`
/// does. A number or an empty string here is an authoring slip, and the
/// alternative is a step that silently runs somewhere else.
/// Refuses a non-string, a blank value, **or a null**, exactly as a `shell`
/// node's `cwd` does. A number or an empty string here is an authoring slip,
/// and the alternative is a step that silently runs somewhere else.
///
/// Null is the one worth spelling out. `cfg` arrives already
/// expression-resolved, so `"cwd": "=nodes.prepare.item.json.worktree"` becomes
/// `null` whenever that path is missing — the upstream node failed, or the key
/// moved. Treating that as "no `cwd` declared" would fall through to
/// `working_dir`, then to the definition's own directory, then to whatever the
/// harness defaults to: the step runs in a *different checkout* and says
/// nothing. A directory an author named is never silently swapped for another,
/// so a present-but-null value fails the node instead.
pub(crate) fn declared_working_dir(cfg: &Value, node_id: &str) -> Result<Option<String>> {
for key in ["cwd", "working_dir"] {
let Some(value) = cfg.get(key).filter(|v| !v.is_null()) else {
let Some(value) = cfg.get(key) else {
continue;
};
if value.is_null() {
return Err(EngineError::Capability(format!(
"agent node {node_id}: `{key}` resolved to null; an expression that reads a \
missing path fails the step rather than falling back to another directory"
)));
}
let dir = value.as_str().ok_or_else(|| {
EngineError::Capability(format!("agent node {node_id}: `{key}` must be a string"))
})?;
Expand All @@ -225,13 +240,19 @@ pub(crate) fn declared_working_dir(cfg: &Value, node_id: &str) -> Result<Option<
/// # Errors
/// Returns [`EngineError::Capability`] when the directory escapes the
/// workspace, does not exist, or is not a directory.
pub(crate) fn resolve_working_dir(ctx: &NodeContext<'_>, raw: &str, key: &str) -> Result<String> {
pub(crate) async fn resolve_working_dir(
ctx: &NodeContext<'_>,
raw: &str,
key: &str,
) -> Result<String> {
crate::workdir::resolve_node_dir(
ctx.caps.agent.as_ref(),
ctx.run,
raw,
&format!("config.{key}"),
&format!("agent node {}", ctx.node.id),
)
.await
}

/// Resolves each declared [`ContextSource`] into a [`ContextBlock`], in
Expand Down Expand Up @@ -400,7 +421,7 @@ pub(crate) async fn assemble(
} else {
"working_dir"
};
agent.working_dir = Some(resolve_working_dir(ctx, &raw, key)?);
agent.working_dir = Some(resolve_working_dir(ctx, &raw, key).await?);
}
let identity = identity_of(ctx, item_index);
let conn = cfg.get("connection_ref").and_then(Value::as_str);
Expand Down
20 changes: 12 additions & 8 deletions src/nodes/integration/sub_workflow/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ fn pause_for_child_gates(node_id: &str, gates: Vec<String>) -> NodeOutput {
/// (see [`crate::workdir`]). A parent run with no workspace has nothing to
/// contain the value against, so it is taken as written: that is how a graph
/// declares a workspace for a child when the run itself was never pinned to one.
fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result<Option<String>> {
async fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result<Option<String>> {
let Some(declared) = ctx.node.config.get("workspace").filter(|v| !v.is_null()) else {
return Ok(crate::workdir::run_workspace(ctx.run).map(str::to_string));
};
Expand All @@ -143,12 +143,16 @@ fn child_workspace(ctx: &NodeContext<'_>, scope: &Value) -> Result<Option<String
ctx.node.id
)));
}
Ok(Some(crate::workdir::resolve_node_dir(
ctx.run,
raw,
"config.workspace",
&format!("sub_workflow node {}", ctx.node.id),
)?))
Ok(Some(
crate::workdir::resolve_node_dir(
ctx.caps.agent.as_ref(),
ctx.run,
raw,
"config.workspace",
&format!("sub_workflow node {}", ctx.node.id),
)
.await?,
))
}

/// What one child run produced, from the parent node's point of view.
Expand Down Expand Up @@ -259,7 +263,7 @@ async fn run_child(
// the child run's workspace, held to the same containment rule as an
// `agent` node's `cwd`: it must resolve inside the parent's workspace. With
// no override the child inherits the parent's.
let child_workspace = child_workspace(ctx, scope)?;
let child_workspace = child_workspace(ctx, scope).await?;
// Box the recursive engine call so the async future type stays sized.
// Forward the parent run's cancellation token: cancelling the parent must
// wind down this child too, rather than letting it run on orphaned behind a
Expand Down
Loading