fabro/lib/crates/fabro-workflow/src/handler/command.rs
fabro-sh-0530[bot] 59a4afa188
Make run actors and provenance total; remove Principal::Anonymous (#463)
## Summary

Removes `Principal::Anonymous` as an actor variant and makes run
creator/provenance non-optional across the full stack — Rust types,
OpenAPI schema, generated TypeScript client, and frontend components.
Every persisted run now has a mandatory creator; there are no nullable
`created_by`, `provenance`, or `subject` fields anywhere in the system.

As a separate cleanup, all `cargo` invocations in CI and dev tooling
gain `--locked` to prevent unintended dependency resolution drift.

### Plan Summary

- **Commit 1 – Remove `Principal::Anonymous`**:
`RequestAuthContext.principal` becomes `Option<Principal>`; `initial()`
and `rejected()` set `None`; all auth gate helpers match on
`Option<Principal>`; HTTP log computes `principal_kind` as
`principal.as_ref().map_or("none", Principal::kind)`;
`PrincipalAnonymous` removed from the OpenAPI schema and generated
client; frontend `principalDisplay` drops the `"anonymous"` case.

- **Commit 2 – Total provenance**: `RunProvenance.subject`,
`RunSpec.provenance`, `Run.created_by`, and `RunCreatedProps.provenance`
all become non-optional. `Default` derive dropped from `RunProvenance`.
Demo mode gets a synthetic `DEMO_PRINCIPAL` via `AuthMethod::DevToken`.
A new `fabro-types` `test-support` feature exports `test_principal()`
and `test_run_provenance()` used across all affected crates and TS
tests. All `provenance: None` / `created_by: null` literals in tests are
replaced with the test fixture.

- **`--locked` CI hardening**: Every `cargo` invocation in GitHub
Actions workflows and `fabro-dev` tooling commands now passes
`--locked`.

### Key design decisions

- `None` principal (unauthenticated / rejected requests) is now
structurally distinct from any actor variant — no sentinel value that
could accidentally pass actor checks.
- `RunProvenance` no longer implements `Default` because a provenance
with no subject is incoherent; callers must supply a real actor at
construction time.
- The `cancel_run` handler was refactored as part of this change to
correctly handle in-process blocked runs: a new test
(`cancel_durably_blocked_in_process_run_cancels_pending_interview_without_abort_signal`)
covers the cancellation path that should let the workflow unwind rather
than aborting it.
- Test fixtures live under the existing `test-support` feature flag
pattern; no fake-auth helpers were added to the `fixtures` module.


### Fabro Details

<details>
<summary>Ran 9 stages in 129m 57s for $53.54</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 10s | – | 0 |
| preflight_lint | 2m 22s | – | 0 |
| implement | 76m 55s | $47.53 | 0 |
| simplify_opus | 14m 46s | $3.21 | 0 |
| simplify_gpt | 2m 26s | $1.04 | 0 |
| verify | 14m 44s | – | 0 |
| fixup | 15m 43s | $1.76 | 0 |
| **Total** | **129m 57s** | **$53.54** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <19+brynary@users.noreply.github.com>
Co-authored-by: Release Repro <release-repro@example.com>
2026-05-31 11:18:01 -04:00

1416 lines
49 KiB
Rust

use std::path::Path;
use async_trait::async_trait;
use fabro_agent::CommandOutputCallback;
use fabro_graphviz::graph::{Graph, Node};
use fabro_types::{CommandTermination, StageTiming};
use super::{EngineServices, Handler, NodeTimeoutPolicy};
use crate::command_log::CommandLogRecorder;
use crate::context::{Context, keys};
use crate::error::Error;
use crate::event::{Event, StageScope};
use crate::outcome::{Outcome, OutcomeExt};
fn timeout_ms(node: &Node) -> Option<u64> {
node.timeout().map(crate::millis_u64)
}
/// Shell-escape a string using `shlex::try_quote` (POSIX-safe).
fn shell_quote(s: &str) -> String {
shlex::try_quote(s).map_or_else(
|_| format!("'{}'", s.replace('\'', "'\\''")),
|q| q.to_string(),
)
}
/// Executes an external script configured via node attributes.
pub struct CommandHandler;
#[async_trait]
impl Handler for CommandHandler {
async fn simulate(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let mut outcome = Outcome::simulated(&node.id);
outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
outcome
.context_updates
.insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(""));
Ok(outcome)
}
async fn execute(
&self,
node: &Node,
context: &Context,
_graph: &Graph,
run_dir: &Path,
services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
if script.is_empty() {
return Ok(Outcome::fail_classify("No script specified"));
}
let language = node
.attrs
.get("language")
.and_then(|v| v.as_str())
.unwrap_or("shell");
if language != "shell" && language != "python" {
return Ok(Outcome::fail_classify(format!(
"Invalid language: {language:?} (expected \"shell\" or \"python\")"
)));
}
let command = if language == "python" {
format!("python3 -c {}", shell_quote(script))
} else {
script.to_string()
};
let command = format!("exec 2>&1\n{command}");
let stage_scope = StageScope::for_handler(context, &node.id);
services.run.emitter.emit_scoped(
&Event::CommandStarted {
node_id: node.id.clone(),
script: script.to_string(),
command: command.clone(),
language: language.to_string(),
timeout_ms: timeout_ms(node),
},
&stage_scope,
);
let timeout_ms = node.timeout().map_or(600_000, crate::millis_u64);
let env = services
.env_for_stage()
.await
.map_err(|err| Error::handler_with_anyhow("Failed to resolve stage env", err))?;
let env_vars = if env.is_empty() { None } else { Some(&env) };
let cancel_token = services.run.cancel_token().child_token();
let stage_id = stage_scope.stage_id();
let recorder = CommandLogRecorder::create(run_dir, &stage_id).await?;
let output_callback: CommandOutputCallback = {
let recorder = recorder.clone();
std::sync::Arc::new(move |_stream, bytes| {
let recorder = recorder.clone();
Box::pin(async move {
recorder
.append(&bytes)
.await
.map_err(|err| fabro_sandbox::Error::message(err.to_string()))
})
})
};
let result = services
.run
.sandbox
.exec_command_streaming(
&command,
Some(timeout_ms),
None,
env_vars,
Some(cancel_token.clone()),
output_callback,
)
.await;
cancel_token.cancel();
let streaming = match result {
Ok(streaming) => streaming,
Err(err) => {
recorder.discard().await?;
return Err(Error::handler_with_source("Failed to spawn script", err));
}
};
let result = streaming.result;
let finalized = recorder.finalize(&services.run.run_store).await?;
services.run.emitter.emit_scoped(
&Event::CommandCompleted {
node_id: node.id.clone(),
output: finalized.output_ref.clone(),
exit_code: result.exit_code,
duration_ms: result.duration_ms,
termination: result.termination,
output_bytes: finalized.output_bytes,
live_streaming: streaming.live_streaming,
},
&stage_scope,
);
if result.termination == CommandTermination::TimedOut {
let mut reason = format!("Script timed out after {timeout_ms}ms: {script}");
append_output_tail(&mut reason, &finalized.output_text);
return Err(Error::handler(reason));
}
if result.termination == CommandTermination::Cancelled {
let mut reason = format!("Script cancelled: {script}");
append_output_tail(&mut reason, &finalized.output_text);
return Err(Error::handler(reason));
}
if result.exit_code == Some(0) {
let mut outcome = Outcome::success();
outcome.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!(finalized.output_ref),
);
outcome.notes = Some(format!("Script completed: {script}"));
outcome.timing = Some(StageTiming::active_only(0, result.duration_ms));
Ok(outcome)
} else {
let mut reason = format!(
"Script failed with exit code: {}",
result.exit_code.unwrap_or(-1)
);
append_output_tail(&mut reason, &finalized.output_text);
let mut outcome = Outcome::fail_classify(reason);
outcome.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!(finalized.output_ref),
);
outcome.timing = Some(StageTiming::active_only(0, result.duration_ms));
Ok(outcome)
}
}
fn node_timeout_policy(&self, _node: &Node) -> NodeTimeoutPolicy {
NodeTimeoutPolicy::HandlerManaged
}
}
fn append_output_tail(reason: &mut String, output: &str) {
let output_tail = tail_bytes(output, 4096);
if !output_tail.trim().is_empty() {
reason.push_str("\n\n## output\n");
reason.push_str(&output_tail);
}
}
fn tail_bytes(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut start = text.len() - max_bytes;
while !text.is_char_boundary(start) {
start += 1;
}
text[start..].to_string()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{Database, RunDatabase, StageId};
use fabro_types::{Graph, RunProjection, RunSpec, WorkflowSettings, fixtures, test_support};
use object_store::memory::InMemory;
use tokio::sync::Mutex;
use super::*;
use crate::command_log::command_log_path;
use crate::outcome::StageOutcome;
use crate::runtime_store::{RunStoreBackend, RunStoreHandle};
#[derive(Default)]
struct MemoryRunStoreBackend {
blobs: Mutex<std::collections::HashMap<fabro_types::RunBlobId, Bytes>>,
}
#[async_trait::async_trait]
impl RunStoreBackend for MemoryRunStoreBackend {
async fn load_state(&self) -> anyhow::Result<fabro_store::RunProjection> {
Ok(RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
git: None,
fork_source_ref: None,
},
chrono::Utc::now(),
))
}
async fn list_events(&self) -> anyhow::Result<Vec<fabro_store::EventEnvelope>> {
Ok(Vec::new())
}
async fn append_run_event(&self, _event: &fabro_types::RunEvent) -> anyhow::Result<()> {
Ok(())
}
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::RunBlobId> {
let blob_id = fabro_types::RunBlobId::new(data);
self.blobs
.lock()
.await
.insert(blob_id, Bytes::copy_from_slice(data));
Ok(blob_id)
}
async fn read_blob(&self, id: &fabro_types::RunBlobId) -> anyhow::Result<Option<Bytes>> {
Ok(self.blobs.lock().await.get(id).cloned())
}
async fn read_run_log(&self) -> anyhow::Result<Option<Vec<u8>>> {
Ok(None)
}
}
fn make_services() -> EngineServices {
let mut services = EngineServices::test_default();
services.run = services.run.with_run_store(RunStoreHandle::new(Arc::new(
MemoryRunStoreBackend::default(),
)));
services
}
async fn command_text(services: &EngineServices, value: &serde_json::Value) -> String {
crate::artifact::resolve_text_or_blob_ref(value, &services.run.run_store)
.await
.unwrap()
}
async fn command_log_text(services: &EngineServices, value: &str) -> String {
crate::command_log::read_json_string_blob(&services.run.run_store, value)
.await
.unwrap()
.unwrap_or_else(|| value.to_string())
}
fn test_store() -> Arc<Database> {
Arc::new(Database::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
))
}
async fn make_services_with_run_store() -> (
EngineServices,
RunDatabase,
crate::event::StoreProgressLogger,
) {
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
seed_created(&run_store).await;
let mut services = EngineServices::test_default();
services.run = services
.run
.with_emitter(Arc::new(crate::event::Emitter::new(fixtures::RUN_1)))
.with_run_store(run_store.clone().into());
let logger = crate::event::StoreProgressLogger::new(run_store.clone());
logger.register(services.run.emitter.as_ref());
(services, run_store, logger)
}
async fn seed_created(run_store: &RunDatabase) {
crate::event::append_event(
run_store,
&fixtures::RUN_1,
&crate::event::Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
workflow_config: None,
labels: std::collections::BTreeMap::default(),
run_dir: "/tmp".to_string(),
source_directory: None,
workflow_slug: None,
automation: None,
db_prefix: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
)
.await
.unwrap();
}
#[tokio::test]
async fn script_handler_no_script() {
let handler = CommandHandler;
let node = Node::new("script_node");
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(outcome.failure_reason(), Some("No script specified"));
}
#[tokio::test]
async fn simulate_skips_execution() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.simulate(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
assert_eq!(
outcome.context_updates.get(keys::COMMAND_OUTPUT),
Some(&serde_json::json!(""))
);
assert!(!outcome.context_updates.contains_key("command.stderr"));
}
#[tokio::test]
async fn dispatch_routes_to_simulate_in_dry_run() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_services();
services.dry_run = true;
let outcome = crate::handler::dispatch_handler(
&handler,
&node,
&context,
&graph,
run_dir.path(),
&services,
)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
}
#[tokio::test]
async fn script_handler_echo_command() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("hello")
);
assert!(!outcome.context_updates.contains_key("command.stderr"));
}
#[tokio::test]
async fn script_handler_reports_command_duration_as_tool_timing() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("sleep 0.05; echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
let timing = outcome.timing.expect("command outcome should carry timing");
assert_eq!(timing.inference_time_ms, 0);
assert!(
timing.tool_time_ms >= 25,
"expected command duration to be reported as tool time, got {timing:?}"
);
assert_eq!(timing.active_time_ms, timing.tool_time_ms);
}
#[tokio::test]
async fn script_handler_failing_command() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("false".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
}
#[tokio::test]
async fn script_handler_timeout() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("sleep 60".to_string()),
);
node.attrs.insert(
"timeout".to_string(),
AttrValue::Duration(Duration::from_millis(50)),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let err = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("timed out"),
"expected timeout message, got: {msg}"
);
}
#[tokio::test]
async fn writes_script_invocation_json() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let json = node_state.script_invocation.as_ref().unwrap();
assert_eq!(json["command"], "exec 2>&1\necho hello");
assert_eq!(json["language"], "shell");
assert_eq!(json["timeout_ms"], serde_json::Value::Null);
}
#[tokio::test]
async fn writes_script_invocation_json_with_timeout() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
node.attrs.insert(
"timeout".to_string(),
AttrValue::Duration(Duration::from_secs(5)),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let json = node_state.script_invocation.as_ref().unwrap();
assert_eq!(json["command"], "exec 2>&1\necho hello");
assert_eq!(json["language"], "shell");
assert_eq!(json["timeout_ms"], 5000);
}
#[tokio::test]
async fn writes_output_log() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let output = node_state.output.as_deref().unwrap();
assert_eq!(command_log_text(&services, output).await.trim(), "hello");
assert_eq!(node_state.output_bytes, Some(6));
assert_eq!(node_state.live_streaming, Some(true));
}
#[tokio::test]
async fn writes_stderr_to_output_log_on_failure() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo oops >&2 && false".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let output = node_state.output.as_deref().unwrap();
assert_eq!(command_log_text(&services, output).await.trim(), "oops");
}
#[tokio::test]
async fn writes_script_timing_json_on_success() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let json = node_state.script_timing.as_ref().unwrap();
assert!(json["duration_ms"].is_u64());
assert_eq!(json["exit_code"], 0);
assert_eq!(json["termination"], "exited");
}
#[tokio::test]
async fn writes_script_timing_json_on_failure() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("false".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let json = node_state.script_timing.as_ref().unwrap();
assert_eq!(json["exit_code"], 1);
assert_eq!(json["termination"], "exited");
}
#[tokio::test]
async fn writes_script_timing_json_on_timeout() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("sleep 60".to_string()),
);
node.attrs.insert(
"timeout".to_string(),
AttrValue::Duration(Duration::from_millis(50)),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
let _err = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap_err();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
let json = node_state.script_timing.as_ref().unwrap();
assert!(json["duration_ms"].is_u64());
assert_eq!(json["exit_code"], serde_json::Value::Null);
assert_eq!(json["termination"], "timed_out");
}
#[tokio::test]
async fn stores_script_invocation_and_timing_in_run_store() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let (services, run_store, logger) = make_services_with_run_store().await;
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
let snapshot = run_store.state().await.unwrap();
let node = snapshot
.stage(&StageId::new("script_node", 1))
.cloned()
.unwrap();
assert_eq!(node.script_invocation.unwrap()["script"], "echo hello");
assert_eq!(node.script_timing.unwrap()["exit_code"], 0);
}
#[tokio::test]
async fn script_handler_python_echo() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("print('hello from python')".to_string()),
);
node.attrs.insert(
"language".to_string(),
AttrValue::String("python".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("hello from python")
);
}
#[tokio::test]
async fn script_handler_python_failure() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("raise Exception('boom')".to_string()),
);
node.attrs.insert(
"language".to_string(),
AttrValue::String("python".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
}
#[tokio::test]
async fn script_handler_invalid_language() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
node.attrs.insert(
"language".to_string(),
AttrValue::String("ruby".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert!(
outcome
.failure_reason()
.unwrap()
.contains("Invalid language")
);
}
#[tokio::test]
async fn tool_command_attribute_fallback() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"tool_command".to_string(),
AttrValue::String("echo legacy".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("legacy")
);
}
#[tokio::test]
async fn script_handler_merges_stderr_into_output() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo out && echo err >&2".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("err"),
"command.output should contain 'err', got: {:?}",
command_output
);
}
/// A sandbox that returns a canned `ExecResult` and captures the command,
/// proving that `CommandHandler` delegates to the sandbox rather than
/// spawning a host process.
struct SpySandbox {
exec_result: fabro_agent::sandbox::ExecResult,
exec_error: Option<String>,
captured_command: std::sync::Mutex<Option<String>>,
captured_env_vars: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
captured_cancel_token: std::sync::Mutex<Option<bool>>,
}
impl SpySandbox {
fn new(exec_result: fabro_agent::sandbox::ExecResult) -> Self {
Self {
exec_result,
exec_error: None,
captured_command: std::sync::Mutex::new(None),
captured_env_vars: std::sync::Mutex::new(None),
captured_cancel_token: std::sync::Mutex::new(None),
}
}
fn fail(message: impl Into<String>) -> Self {
Self {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(1),
termination: CommandTermination::Exited,
duration_ms: 0,
},
exec_error: Some(message.into()),
captured_command: std::sync::Mutex::new(None),
captured_env_vars: std::sync::Mutex::new(None),
captured_cancel_token: std::sync::Mutex::new(None),
}
}
fn captured_command(&self) -> Option<String> {
self.captured_command.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl fabro_agent::sandbox::Sandbox for SpySandbox {
async fn read_file_bytes(&self, _: &str) -> fabro_sandbox::Result<Vec<u8>> {
unimplemented!()
}
async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn file_exists(&self, _: &str) -> fabro_sandbox::Result<bool> {
unimplemented!()
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
unimplemented!()
}
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> fabro_sandbox::Result<fabro_agent::sandbox::ExecResult> {
*self.captured_command.lock().unwrap() = Some(command.to_string());
*self.captured_env_vars.lock().unwrap() = env_vars.cloned();
*self.captured_cancel_token.lock().unwrap() = Some(cancel_token.is_some());
if let Some(message) = self.exec_error.as_ref() {
return Err(fabro_sandbox::Error::message(message.clone()));
}
Ok(self.exec_result.clone())
}
async fn grep(
&self,
_: &str,
_: &str,
_: &fabro_agent::sandbox::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
"/mock"
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Mock".into()
}
}
fn make_spy_services(sandbox: std::sync::Arc<SpySandbox>) -> EngineServices {
let mut services = make_services();
services.run = services.run.with_sandbox(sandbox);
services
}
struct RefreshingMinter {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl crate::github_token_source::IatMinter for RefreshingMinter {
async fn mint(&self) -> anyhow::Result<fabro_github::InstallationToken> {
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
Ok(fabro_github::InstallationToken {
token: format!("ghs_{call}"),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
})
}
}
#[tokio::test]
async fn executes_script_via_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "SANDBOX_MARKER\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_spy_services(spy.clone());
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert_eq!(
command_text(&services, command_output).await,
"SANDBOX_MARKER\n",
"CommandHandler must delegate to the sandbox, not spawn a host process"
);
assert_eq!(
spy.captured_command().as_deref(),
Some("exec 2>&1\necho hello"),
"sandbox should receive the wrapped script as the command"
);
}
#[tokio::test]
async fn executes_python_script_via_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "PYTHON_SANDBOX\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("print('hi')".to_string()),
);
node.attrs.insert(
"language".to_string(),
AttrValue::String("python".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(
&node,
&context,
&graph,
run_dir.path(),
&make_spy_services(spy.clone()),
)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let captured = spy.captured_command().unwrap();
assert!(
captured.starts_with("exec 2>&1\npython3 -c ") && captured.contains("print"),
"sandbox command should invoke python3 with the script, got: {captured}"
);
}
#[tokio::test]
async fn passes_env_vars_to_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("true".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_spy_services(spy.clone());
services
.base_env
.insert("MY_VAR".to_string(), "my_value".to_string());
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
let captured_env = spy.captured_env_vars.lock().unwrap().clone().unwrap();
assert_eq!(
captured_env.get("MY_VAR").map(String::as_str),
Some("my_value")
);
}
#[tokio::test]
async fn refreshes_github_token_for_each_command_stage_when_near_expiry() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let minter = std::sync::Arc::new(RefreshingMinter {
calls: std::sync::atomic::AtomicUsize::new(0),
});
let mut services = make_spy_services(spy.clone());
services.github_token = Some(std::sync::Arc::new(
crate::github_token_source::GitHubTokenSource::mintable(minter.clone()),
));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("true".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
Some("ghs_1")
);
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
Some("ghs_2")
);
assert_eq!(minter.calls.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn passes_run_cancellation_to_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("true".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_spy_services(spy.clone());
services.run = services
.run
.with_cancel_token(tokio_util::sync::CancellationToken::new());
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(*spy.captured_cancel_token.lock().unwrap(), Some(true));
}
#[tokio::test]
async fn script_handler_timeout_error_includes_output_tails() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "partial stdout\n".into(),
stderr: "partial stderr\n".into(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms: 50,
}));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("sleep 10".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let err = handler
.execute(
&node,
&context,
&graph,
run_dir.path(),
&make_spy_services(spy),
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("timed out"), "got: {message}");
assert!(
message.contains("partial stdout"),
"timeout error should include output tail, got: {message}"
);
assert!(
message.contains("partial stderr"),
"timeout error should include merged output tail, got: {message}"
);
}
#[tokio::test]
async fn tool_output_context_key_not_emitted() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo dual".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT));
assert!(
!outcome.context_updates.contains_key("tool.output"),
"tool.output should not be emitted"
);
}
#[tokio::test]
async fn script_handler_failure_includes_output() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo "build output" && echo "oops" >&2 && exit 1"#.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
let reason = outcome.failure_reason().unwrap();
assert!(
reason.contains("build output"),
"failure_reason should contain output, got: {reason}"
);
assert!(
reason.contains("oops"),
"failure_reason should contain merged stderr, got: {reason}"
);
assert!(
reason.contains("exit code: 1"),
"failure_reason should contain exit code, got: {reason}"
);
}
#[tokio::test]
async fn script_handler_spawn_failure() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_spy_services(std::sync::Arc::new(SpySandbox::fail("No such file")));
let err = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to spawn script"));
let stage_id = StageId::new("script_node", 1);
assert!(
!command_log_path(run_dir.path(), &stage_id).exists(),
"spawn failure should remove pre-created output scratch log"
);
}
#[tokio::test]
async fn script_handler_failure_sets_command_output() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo "build output" && exit 1"#.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
let command_output = outcome
.context_updates
.get(keys::COMMAND_OUTPUT)
.expect("command.output should be set on failure");
assert!(
command_text(&services, command_output)
.await
.contains("build output"),
"command.output should contain output, got: {command_output:?}"
);
}
}