diff --git a/src/openhuman/credentials/http_creds.rs b/src/openhuman/credentials/http_creds.rs
new file mode 100644
index 000000000..96f0a088b
@@ -0,0 +1,519 @@
+//! Named HTTP credentials for `http_request` flow nodes.
+//!
+//! A flow's `http_request` node can carry a `connection_ref` of the shape
+//! `"http_cred:<name>"`. This module is the host-side store those names resolve
+//! against: each record is an **injection template** (bearer token, HTTP basic
+//! user:pass, or a raw custom header) whose secret material is encrypted at
+//! rest with the same [`SecretStore`](crate::openhuman::keyring::SecretStore)
+//! (ChaCha20-Poly1305) the auth-profile store uses.
+//!
+//! **Security contract:** the secret value NEVER leaves this module except as
+//! the header it is injected into, server-side, inside
+//! `tinyflows::caps::OpenHumanHttp::request`. It is never returned to the UI,
+//! handed to the flow engine/graph, or logged. List/summary shapes carry only
+//! the name + scheme + non-secret template fields ([`HttpCredentialSummary`]).
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use base64::engine::Engine as _;
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::openhuman::config::Config;
+use crate::openhuman::keyring::SecretStore;
+
+const STORE_FILENAME: &str = "http-credentials.json";
+const CURRENT_SCHEMA_VERSION: u32 = 1;
+
+/// How a credential is presented on the outbound request.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum HttpCredentialScheme {
+ /// `Authorization: Bearer <secret>`.
+ Bearer,
+ /// `Authorization: Basic base64(<username>:<secret>)`.
+ Basic,
+ /// A raw custom header: `<header_name>: <secret>` (e.g. `X-API-Key`).
+ Header,
+}
+
+impl HttpCredentialScheme {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ HttpCredentialScheme::Bearer => "bearer",
+ HttpCredentialScheme::Basic => "basic",
+ HttpCredentialScheme::Header => "header",
+ }
+ }
+}
+
+/// A resolved HTTP credential, secret in the clear in memory. Produced only by
+/// [`HttpCredentialsStore::get`] and consumed only by the server-side injector.
+#[derive(Debug, Clone)]
+pub struct HttpCredential {
+ pub name: String,
+ pub scheme: HttpCredentialScheme,
+ /// Header name for the [`HttpCredentialScheme::Header`] scheme (e.g.
+ /// `X-API-Key`). Ignored for bearer/basic.
+ pub header_name: Option<String>,
+ /// Username for the [`HttpCredentialScheme::Basic`] scheme. Ignored
+ /// otherwise. Not itself a secret, but stored alongside the secret.
+ pub username: Option<String>,
+ /// The secret material: bearer token, basic password, or raw header value.
+ pub secret: String,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl HttpCredential {
+ pub fn bearer(name: impl Into<String>, token: impl Into<String>) -> Self {
+ let now = Utc::now();
+ Self {
+ name: name.into(),
+ scheme: HttpCredentialScheme::Bearer,
+ header_name: None,
+ username: None,
+ secret: token.into(),
+ created_at: now,
+ updated_at: now,
+ }
+ }
+
+ pub fn basic(
+ name: impl Into<String>,
+ username: impl Into<String>,
+ password: impl Into<String>,
+ ) -> Self {
+ let now = Utc::now();
+ Self {
+ name: name.into(),
+ scheme: HttpCredentialScheme::Basic,
+ header_name: None,
+ username: Some(username.into()),
+ secret: password.into(),
+ created_at: now,
+ updated_at: now,
+ }
+ }
+
+ pub fn header(
+ name: impl Into<String>,
+ header_name: impl Into<String>,
+ value: impl Into<String>,
+ ) -> Self {
+ let now = Utc::now();
+ Self {
+ name: name.into(),
+ scheme: HttpCredentialScheme::Header,
+ header_name: Some(header_name.into()),
+ username: None,
+ secret: value.into(),
+ created_at: now,
+ updated_at: now,
+ }
+ }
+
+ /// The `(header_name, header_value)` pair to inject onto the outbound
+ /// request. **The returned value contains the secret** — callers must merge
+ /// it into the request server-side and must never log or echo it.
+ pub fn to_header(&self) -> Result<(String, String)> {
+ match self.scheme {
+ HttpCredentialScheme::Bearer => {
+ anyhow::ensure!(
+ !self.secret.trim().is_empty(),
+ "http_cred '{}': bearer token is empty",
+ self.name
+ );
+ Ok((
+ "Authorization".to_string(),
+ format!("Bearer {}", self.secret),
+ ))
+ }
+ HttpCredentialScheme::Basic => {
+ let username = self.username.as_deref().unwrap_or_default();
+ let encoded = base64::engine::general_purpose::STANDARD
+ .encode(format!("{username}:{}", self.secret));
+ Ok(("Authorization".to_string(), format!("Basic {encoded}")))
+ }
+ HttpCredentialScheme::Header => {
+ let header_name = self
+ .header_name
+ .as_deref()
+ .map(str::trim)
+ .filter(|h| !h.is_empty())
+ .with_context(|| {
+ format!(
+ "http_cred '{}': header scheme requires a non-empty header_name",
+ self.name
+ )
+ })?;
+ anyhow::ensure!(
+ !self.secret.trim().is_empty(),
+ "http_cred '{}': header value is empty",
+ self.name
+ );
+ Ok((header_name.to_string(), self.secret.clone()))
+ }
+ }
+ }
+}
+
+/// Secret-free description of a stored credential — safe to return to the UI /
+/// list surfaces (e.g. a future `flows_list_connections`).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct HttpCredentialSummary {
+ pub name: String,
+ pub scheme: String,
+ pub header_name: Option<String>,
+ pub username: Option<String>,
+ pub updated_at: String,
+}
+
+/// On-disk record. `secret` is stored as `enc2:<hex>` ciphertext (or plaintext
+/// when `secrets.encrypt = false`, matching the auth-profile store's behavior).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedHttpCredential {
+ scheme: String,
+ #[serde(default)]
+ header_name: Option<String>,
+ #[serde(default)]
+ username: Option<String>,
+ /// Encrypted secret material.
+ secret: String,
+ created_at: String,
+ updated_at: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedHttpCredentials {
+ schema_version: u32,
+ updated_at: String,
+ credentials: BTreeMap<String, PersistedHttpCredential>,
+}
+
+impl Default for PersistedHttpCredentials {
+ fn default() -> Self {
+ Self {
+ schema_version: CURRENT_SCHEMA_VERSION,
+ updated_at: Utc::now().to_rfc3339(),
+ credentials: BTreeMap::new(),
+ }
+ }
+}
+
+/// Encrypted-at-rest store of named HTTP credentials.
+#[derive(Debug, Clone)]
+pub struct HttpCredentialsStore {
+ path: PathBuf,
+ secret_store: SecretStore,
+}
+
+impl HttpCredentialsStore {
+ pub fn from_config(config: &Config) -> Self {
+ let state_dir = super::state_dir_from_config(config);
+ Self::new(&state_dir, config.secrets.encrypt)
+ }
+
+ pub fn new(state_dir: &Path, encrypt_secrets: bool) -> Self {
+ Self {
+ path: state_dir.join(STORE_FILENAME),
+ secret_store: SecretStore::new(state_dir, encrypt_secrets),
+ }
+ }
+
+ /// Normalize a credential name into the stable storage key. Names are
+ /// case-insensitive and trimmed so `http_cred:Stripe ` and `stripe` resolve
+ /// to the same record.
+ fn normalize_name(name: &str) -> String {
+ name.trim().to_ascii_lowercase()
+ }
+
+ /// List all stored credentials as secret-free summaries.
+ pub fn list(&self) -> Result<Vec<HttpCredentialSummary>> {
+ let persisted = self.read_persisted()?;
+ Ok(persisted
+ .credentials
+ .into_iter()
+ .map(|(name, rec)| HttpCredentialSummary {
+ name,
+ scheme: rec.scheme,
+ header_name: rec.header_name,
+ username: rec.username,
+ updated_at: rec.updated_at,
+ })
+ .collect())
+ }
+
+ /// Resolve a credential name to its secret-bearing record, decrypting the
+ /// secret. Returns `Ok(None)` when no such credential exists.
+ pub fn get(&self, name: &str) -> Result<Option<HttpCredential>> {
+ let key = Self::normalize_name(name);
+ let persisted = self.read_persisted()?;
+ let Some(rec) = persisted.credentials.get(&key) else {
+ log::debug!(target: "credentials", "[credentials] http_cred get miss name={key}");
+ return Ok(None);
+ };
+
+ let scheme = parse_scheme(&rec.scheme).with_context(|| {
+ format!("http_cred '{key}' has unrecognized scheme {:?}", rec.scheme)
+ })?;
+ let secret = self
+ .secret_store
+ .decrypt(&rec.secret)
+ .with_context(|| format!("failed to decrypt http_cred '{key}' secret"))?;
+
+ log::debug!(
+ target: "credentials",
+ "[credentials] http_cred get hit name={key} scheme={}",
+ scheme.as_str()
+ );
+ Ok(Some(HttpCredential {
+ name: key,
+ scheme,
+ header_name: rec.header_name.clone(),
+ username: rec.username.clone(),
+ secret,
+ created_at: parse_dt(&rec.created_at),
+ updated_at: parse_dt(&rec.updated_at),
+ }))
+ }
+
+ /// Insert or replace a credential, encrypting its secret at rest.
+ pub fn upsert(&self, cred: &HttpCredential) -> Result<()> {
+ let key = Self::normalize_name(&cred.name);
+ anyhow::ensure!(!key.is_empty(), "http_cred name cannot be empty");
+
+ let mut persisted = self.read_persisted()?;
+ let encrypted = self
+ .secret_store
+ .encrypt(&cred.secret)
+ .context("failed to encrypt http_cred secret")?;
+
+ let created_at = persisted
+ .credentials
+ .get(&key)
+ .map(|r| r.created_at.clone())
+ .unwrap_or_else(|| cred.created_at.to_rfc3339());
+
+ persisted.credentials.insert(
+ key.clone(),
+ PersistedHttpCredential {
+ scheme: cred.scheme.as_str().to_string(),
+ header_name: cred.header_name.clone(),
+ username: cred.username.clone(),
+ secret: encrypted,
+ created_at,
+ updated_at: Utc::now().to_rfc3339(),
+ },
+ );
+ persisted.updated_at = Utc::now().to_rfc3339();
+ self.write_persisted(&persisted)?;
+ log::info!(
+ target: "credentials",
+ "[credentials] http_cred upserted name={key} scheme={} (secret redacted)",
+ cred.scheme.as_str()
+ );
+ Ok(())
+ }
+
+ /// Remove a credential by name. Returns whether a record was removed.
+ pub fn remove(&self, name: &str) -> Result<bool> {
+ let key = Self::normalize_name(name);
+ let mut persisted = self.read_persisted()?;
+ let removed = persisted.credentials.remove(&key).is_some();
+ if removed {
+ persisted.updated_at = Utc::now().to_rfc3339();
+ self.write_persisted(&persisted)?;
+ log::info!(target: "credentials", "[credentials] http_cred removed name={key}");
+ }
+ Ok(removed)
+ }
+
+ fn read_persisted(&self) -> Result<PersistedHttpCredentials> {
+ if !self.path.exists() {
+ return Ok(PersistedHttpCredentials::default());
+ }
+ let bytes = fs::read(&self.path).with_context(|| {
+ format!(
+ "failed to read http-credentials store at {}",
+ self.path.display()
+ )
+ })?;
+ if bytes.is_empty() {
+ return Ok(PersistedHttpCredentials::default());
+ }
+ serde_json::from_slice(&bytes).with_context(|| {
+ format!(
+ "http-credentials store at {} is not valid JSON",
+ self.path.display()
+ )
+ })
+ }
+
+ fn write_persisted(&self, persisted: &PersistedHttpCredentials) -> Result<()> {
+ if let Some(parent) = self.path.parent() {
+ fs::create_dir_all(parent).with_context(|| {
+ format!(
+ "failed to create http-credentials dir at {}",
+ parent.display()
+ )
+ })?;
+ }
+ let json = serde_json::to_vec_pretty(persisted)
+ .context("failed to serialize http-credentials store")?;
+ // Atomic publish: write to a unique tmp then rename over the store so a
+ // concurrent reader never observes a torn file.
+ let tmp_name = format!(
+ "{STORE_FILENAME}.tmp.{}.{}",
+ std::process::id(),
+ Utc::now().timestamp_nanos_opt().unwrap_or_default()
+ );
+ let tmp_path = self.path.with_file_name(tmp_name);
+ fs::write(&tmp_path, &json)
+ .with_context(|| format!("failed to write {}", tmp_path.display()))?;
+ if let Err(e) = fs::rename(&tmp_path, &self.path) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(e).with_context(|| {
+ format!(
+ "failed to replace http-credentials store at {}",
+ self.path.display()
+ )
+ });
+ }
+ Ok(())
+ }
+}
+
+fn parse_scheme(raw: &str) -> Option<HttpCredentialScheme> {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "bearer" => Some(HttpCredentialScheme::Bearer),
+ "basic" => Some(HttpCredentialScheme::Basic),
+ "header" => Some(HttpCredentialScheme::Header),
+ _ => None,
+ }
+}
+
+fn parse_dt(raw: &str) -> DateTime<Utc> {
+ DateTime::parse_from_rfc3339(raw)
+ .map(|dt| dt.with_timezone(&Utc))
+ .unwrap_or_else(|_| Utc::now())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_store() -> (tempfile::TempDir, HttpCredentialsStore) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ // encrypt=true exercises the ChaCha20-Poly1305 at-rest path.
+ let store = HttpCredentialsStore::new(dir.path(), true);
+ (dir, store)
+ }
+
+ #[test]
+ fn bearer_to_header_is_authorization_bearer() {
+ let cred = HttpCredential::bearer("stripe", "sk_live_abc123");
+ let (name, value) = cred.to_header().unwrap();
+ assert_eq!(name, "Authorization");
+ assert_eq!(value, "Bearer sk_live_abc123");
+ }
+
+ #[test]
+ fn basic_to_header_is_base64_user_pass() {
+ let cred = HttpCredential::basic("acme", "alice", "hunter2");
+ let (name, value) = cred.to_header().unwrap();
+ assert_eq!(name, "Authorization");
+ // base64("alice:hunter2")
+ let expected = base64::engine::general_purpose::STANDARD.encode("alice:hunter2");
+ assert_eq!(value, format!("Basic {expected}"));
+ }
+
+ #[test]
+ fn header_scheme_uses_custom_header_name() {
+ let cred = HttpCredential::header("apikey", "X-API-Key", "topsecret");
+ let (name, value) = cred.to_header().unwrap();
+ assert_eq!(name, "X-API-Key");
+ assert_eq!(value, "topsecret");
+ }
+
+ #[test]
+ fn header_scheme_without_header_name_errors() {
+ let mut cred = HttpCredential::header("apikey", "X-API-Key", "topsecret");
+ cred.header_name = None;
+ assert!(cred.to_header().is_err());
+ }
+
+ #[test]
+ fn roundtrip_encrypts_secret_at_rest() {
+ let (dir, store) = temp_store();
+ let secret = "sk_live_super_secret_value";
+ store
+ .upsert(&HttpCredential::bearer("stripe", secret))
+ .unwrap();
+
+ // The on-disk file must NOT contain the plaintext secret.
+ let raw = std::fs::read_to_string(dir.path().join(STORE_FILENAME)).unwrap();
+ assert!(
+ !raw.contains(secret),
+ "plaintext secret leaked into on-disk store: {raw}"
+ );
+ assert!(raw.contains("enc2:"), "secret was not encrypted: {raw}");
+
+ // But get() decrypts it back.
+ let got = store.get("stripe").unwrap().expect("credential present");
+ assert_eq!(got.secret, secret);
+ assert_eq!(got.scheme, HttpCredentialScheme::Bearer);
+ }
+
+ #[test]
+ fn name_resolution_is_case_insensitive_and_trimmed() {
+ let (_dir, store) = temp_store();
+ store
+ .upsert(&HttpCredential::bearer("Stripe", "tok"))
+ .unwrap();
+ assert!(store.get(" STRIPE ").unwrap().is_some());
+ assert!(store.get("stripe").unwrap().is_some());
+ }
+
+ #[test]
+ fn list_never_exposes_secrets() {
+ let (_dir, store) = temp_store();
+ store
+ .upsert(&HttpCredential::header("apikey", "X-API-Key", "topsecret"))
+ .unwrap();
+ let summaries = store.list().unwrap();
+ assert_eq!(summaries.len(), 1);
+ let s = &summaries[0];
+ assert_eq!(s.name, "apikey");
+ assert_eq!(s.scheme, "header");
+ assert_eq!(s.header_name.as_deref(), Some("X-API-Key"));
+ // The summary type has no secret field at all — assert via serialization
+ // that "topsecret" never appears.
+ let json = serde_json::to_string(&summaries).unwrap();
+ assert!(
+ !json.contains("topsecret"),
+ "secret leaked into summary: {json}"
+ );
+ }
+
+ #[test]
+ fn get_unknown_name_returns_none() {
+ let (_dir, store) = temp_store();
+ assert!(store.get("does-not-exist").unwrap().is_none());
+ }
+
+ #[test]
+ fn remove_deletes_record() {
+ let (_dir, store) = temp_store();
+ store
+ .upsert(&HttpCredential::bearer("stripe", "tok"))
+ .unwrap();
+ assert!(store.remove("stripe").unwrap());
+ assert!(store.get("stripe").unwrap().is_none());
+ assert!(!store.remove("stripe").unwrap());
+ }
+}
diff --git a/src/openhuman/credentials/mod.rs b/src/openhuman/credentials/mod.rs
index ce625c81a..4b507cc51 100644
@@ -1,28 +1,32 @@
//! Credential management for app session and provider auth profiles.
pub mod bus;
pub mod cli;
mod core;
+pub mod http_creds;
pub mod ops;
pub mod profiles;
pub mod responses;
mod schemas;
pub mod sentry_scope;
pub mod session_support;
pub mod tools;
pub use crate::api::rest::{
decrypt_handoff_blob, user_id_from_auth_me_payload, user_id_from_profile_payload,
BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff,
};
pub use core::*;
+pub use http_creds::{
+ HttpCredential, HttpCredentialScheme, HttpCredentialSummary, HttpCredentialsStore,
+};
pub use ops as rpc;
pub use ops::*;
// Direct-mode (BYO Composio API key) credential helpers.
pub use ops::{
clear_composio_api_key, get_composio_api_key, store_composio_api_key, COMPOSIO_DIRECT_PROVIDER,
};
pub use schemas::{
all_controller_schemas as all_credentials_controller_schemas,
all_registered_controllers as all_credentials_registered_controllers,
};
diff --git a/src/openhuman/flows/mod.rs b/src/openhuman/flows/mod.rs
index d70743a04..95d4b799b 100644
@@ -1,32 +1,32 @@
//! The `flows::` domain: saved automation workflows (tinyflows graphs) —
//! create/get/list/update/delete/enable/run, backed by SQLite. Mirrors
//! `src/openhuman/cron/`'s module shape.
//!
//! Business logic lives in [`ops`]; persistence in `store` (private, with a
//! handful of functions re-exported below for the capability seam's
//! [`crate::openhuman::tinyflows::caps::FlowStateStore`]); the RPC/CLI
//! controller surface in `schemas` (private, re-exported below).
pub mod bus;
pub mod ops;
mod run_registry;
mod schemas;
mod store;
pub mod tools;
mod types;
pub use schemas::{
all_controller_schemas as all_flows_controller_schemas,
all_registered_controllers as all_flows_registered_controllers,
};
// `kv_get`/`kv_set` are re-exported (not just `pub(crate)`-visible within this
// domain's own module tree) because `tinyflows::caps::FlowStateStore`
// (`src/openhuman/tinyflows/caps.rs`) lives in a sibling domain and needs
// them to implement `tinyflows::caps::StateStore` without duplicating the
// `flow_state` table's persistence logic.
// `upsert_flow_run_step` is likewise re-exported for the tinyflows seam: the
// live run observer (`tinyflows::observability::FlowRunObserver`, issue G2)
// lives in the sibling `tinyflows` domain and persists each finished step onto
// the `flow_runs` row through this function as the run executes.
pub use store::{kv_get, kv_set, upsert_flow_run_step};
-pub use types::{Flow, FlowRun, FlowRunStep, FlowRunTrigger, FlowValidation};
+pub use types::{Flow, FlowConnection, FlowRun, FlowRunStep, FlowRunTrigger, FlowValidation};
diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs
index e289125b2..36596bc51 100644
@@ -1,115 +1,154 @@
//! Business logic for the `flows::` domain: validate-on-save CRUD plus the
//! end-to-end `flows_run` / `flows_resume` path. Delegated to from
//! `schemas.rs`'s `handle_*` RPC/CLI handlers, mirroring
//! `src/openhuman/cron/ops.rs`.
use std::sync::Arc;
use chrono::Utc;
use serde_json::{json, Value};
use tinyflows::model::{TriggerKind, WorkflowGraph};
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
use crate::openhuman::config::Config;
use crate::openhuman::flows::bus;
use crate::openhuman::flows::run_registry;
use crate::openhuman::flows::store;
-use crate::openhuman::flows::types::{FlowRunStep, FlowRunTrigger};
+use crate::openhuman::flows::types::{FlowConnection, FlowRunStep, FlowRunTrigger};
use crate::openhuman::flows::{Flow, FlowRun};
use crate::rpc::RpcOutcome;
/// Overall safety bound on a single `flows_run` / `flows_resume`. Individual
/// capabilities have their own timeouts (HTTP, sandbox), but a hung LLM/tool
/// call must never let the RPC block indefinitely — this caps the whole run.
const FLOW_RUN_TIMEOUT_SECS: u64 = 600;
/// How long a run may sit parked at a human-in-the-loop approval gate
/// (`pending_approval`) before the TTL sweep expires it to a terminal
/// `"cancelled"` (issue G4). Aligned with the agent tool-call `ApprovalGate`'s
/// 10-minute fail-closed TTL (`src/openhuman/approval/`), so a flow HITL gate a
/// human never answers doesn't wedge a run — and its durable checkpoint —
/// forever. The two are distinct mechanisms (flow runs execute as
/// `TrustedAutomation { Workflow }`, which the tool-call gate lets through), so
/// this is a dedicated flows-side TTL, not a reuse of the approval store's.
const FLOW_PARKED_TTL_SECS: i64 = 600;
+// ─────────────────────────────────────────────────────────────────────────────
+// Phase 2 — autonomy-tier gating of acting flow nodes
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// A `flows_run` / `flows_resume` executes under a `TrustedAutomation { Workflow }`
+// origin (see `workflow_origin` below), but the *acting power* of a run is still
+// bounded by the user's `[autonomy]` tier — the same `SecurityPolicy`
+// (`src/openhuman/security/`) the agent tool-loop honors, built via
+// `SecurityPolicy::from_config(&config.autonomy, …)` inside
+// `tinyflows::caps::build_capabilities`.
+//
+// Before an acting node dispatches, its capability adapter
+// (`src/openhuman/tinyflows/caps.rs::enforce_node_tier_gate`) maps the node to a
+// `CommandClass` and consults `SecurityPolicy::gate_decision`. `Block` refuses
+// outright (`[policy-blocked]` error, no dispatch); `Prompt`/`Allow` fall through
+// to the process-global `ApprovalGate`, which performs the human round-trip for
+// `Prompt` exactly as the agent tool-loop does. Node → class → per-tier decision:
+//
+// Flow node CommandClass read-only supervised full
+// ──────────── ──────────── ────────── ────────── ──────────
+// http_request Network BLOCK Prompt Prompt
+// code Write BLOCK Prompt Allow
+// tool_call (curation + (curated + Prompt Prompt/Allow¹
+// ApprovalGate) scope gate)
+// agent (llm) — (no acting side effect; not tier-gated, only the
+// inference/privacy chokepoint applies)
+// state (kv) — (host-internal flow KV; not an outbound act)
+//
+// ¹ tool_call routes through the deny-by-default curation/scope gate plus the
+// ApprovalGate rather than `gate_decision`; a Network-class Composio action
+// still prompts under supervised/full and the curation gate is the hard
+// allowlist. See `caps.rs::OpenHumanTools`.
+//
+// `Network` is never `Allow` in any tier (always `Prompt` when not blocked), so
+// even a full-tier http_request node prompts unless a pre-declared trust root /
+// `auto_approve` short-circuits the ApprovalGate — matching `curl`/`shell`.
+// `Write` (code) is `Allow` under full, so trusted automations run sandboxed
+// code unattended; read-only blocks both outright.
+
/// Runs a raw graph JSON value through `tinyflows::migrate::migrate` (upgrade
/// an older-schema definition to current), deserializes it, and rejects a
/// structurally invalid graph via `tinyflows::validate::validate` — so a bad
/// graph is caught at the door, before it's ever persisted.
///
/// `pub(crate)` (not private) so `flows::tools::ProposeWorkflowTool` (issue
/// B4 — agent-first workflow authoring) can run a candidate graph through the
/// exact same validate/migrate path `flows_create` uses below, without
/// duplicating it. The tool only calls this — never `flows_create` itself —
/// which is what keeps the "the agent can never create a flow" invariant
/// intact: this function validates and returns, it has no persistence effect.
pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result<WorkflowGraph, String> {
let migrated = tinyflows::migrate::migrate(graph_json).map_err(|e| e.to_string())?;
let graph: WorkflowGraph = serde_json::from_value(migrated).map_err(|e| e.to_string())?;
tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?;
Ok(graph)
}
/// Stable snake_case label for a [`TriggerKind`], matching its serde wire
/// discriminator — used in loud author-facing warnings (not derived via serde
/// so the exact human string is unmistakable at the call site).
fn trigger_kind_label(kind: &TriggerKind) -> &'static str {
match kind {
TriggerKind::Manual => "manual",
TriggerKind::Schedule => "schedule",
TriggerKind::Webhook => "webhook",
TriggerKind::AppEvent => "app_event",
TriggerKind::Form => "form",
TriggerKind::ExecuteByWorkflow => "execute_by_workflow",
TriggerKind::ChatMessage => "chat_message",
TriggerKind::Evaluation => "evaluation",
TriggerKind::System => "system",
}
}
/// Whether a flow's trigger kind currently produces *automatic* runs in this
/// host. Only three kinds fire today:
/// - `manual` — runnable on demand via `flows_run` (no automatic dispatch, but
/// that's the whole contract of a manual trigger — never a surprise).
/// - `schedule` — a `cron` job drives `FlowScheduleTick` (see
/// [`bind_schedule_trigger`]).
/// - `app_event` — matched against `ComposioTriggerReceived` at dispatch time
/// (see `flows::bus::FlowTriggerSubscriber`).
///
/// Everything else (`webhook`, `chat_message`, `form`, `execute_by_workflow`,
/// `evaluation`, `system`) is *accepted and saved* but has no wired dispatch
/// path yet — enabling such a flow silently produces a flow that never runs
/// itself. [`graph_trigger_warnings`] turns that silence into a loud warning.
fn trigger_kind_fires(kind: &TriggerKind) -> bool {
matches!(
kind,
TriggerKind::Manual | TriggerKind::Schedule | TriggerKind::AppEvent
)
}
/// Produces host-side, **non-fatal** validation warnings for a graph — today
/// exactly one: "this trigger kind does not fire automatically yet". Returns
/// an empty vec when the trigger fires (`manual`/`schedule`/`app_event`), when
/// the graph has no single resolvable trigger node, or when the trigger has no
/// `trigger_kind` discriminator (a legacy/manual-only graph authored before
/// B2 simply never self-fires — not a warnable surprise, matching
/// `bus::extract_trigger_kind`'s "no automatic binding" treatment).
///
/// This lives host-side (NOT in `tinyflows::validate`, which is host-agnostic
/// and only does structural checks) because "which trigger kinds this host has
/// wired" is an OpenHuman fact, not a property of the portable graph.
pub(crate) fn graph_trigger_warnings(graph: &WorkflowGraph) -> Vec<String> {
let Some(trigger) = graph.trigger() else {
return Vec::new();
};
let Some(kind_value) = trigger.config.get("trigger_kind") else {
return Vec::new();
};
let kind: TriggerKind = match serde_json::from_value(kind_value.clone()) {
Ok(k) => k,
Err(_) => return Vec::new(),
};
if trigger_kind_fires(&kind) {
return Vec::new();
}
@@ -132,160 +171,335 @@ pub(crate) fn graph_trigger_warnings(graph: &WorkflowGraph) -> Vec<String> {
pub fn flows_validate(graph_json: Value) -> RpcOutcome<crate::openhuman::flows::FlowValidation> {
use crate::openhuman::flows::FlowValidation;
tracing::debug!(target: "flows", "[flows] flows_validate: validating candidate graph");
match validate_and_migrate_graph(graph_json) {
Ok(graph) => {
let warnings = graph_trigger_warnings(&graph);
for warning in &warnings {
tracing::warn!(target: "flows", warning = %warning, "[flows] flows_validate: non-fatal validation warning");
}
tracing::debug!(
target: "flows",
node_count = graph.nodes.len(),
warning_count = warnings.len(),
"[flows] flows_validate: graph is structurally valid"
);
RpcOutcome::single_log(
FlowValidation {
valid: true,
errors: Vec::new(),
warnings,
},
"flow validated",
)
}
Err(error) => {
tracing::debug!(target: "flows", %error, "[flows] flows_validate: graph is structurally invalid");
RpcOutcome::single_log(
FlowValidation {
valid: false,
errors: vec![error],
warnings: Vec::new(),
},
"flow validation failed",
)
}
}
}
/// Creates a new flow from a name and a raw graph JSON value.
///
/// `store::create_flow` defaults new flows to `enabled = true` — this binds
/// the flow's automatic-dispatch side effect (e.g. registers the
/// schedule-trigger cron job) immediately, reusing the same [`bind_trigger`]
/// helper `flows_set_enabled` uses. Without this, a freshly-created enabled
/// schedule flow would silently never fire until an app restart (boot
/// reconcile) or a manual disable→enable toggle. Best-effort, same as
/// `flows_set_enabled`: a binding failure is logged, not fatal to create.
pub async fn flows_create(
config: &Config,
name: String,
graph_json: Value,
require_approval: bool,
) -> Result<RpcOutcome<Flow>, String> {
let graph = validate_and_migrate_graph(graph_json)?;
tracing::debug!(target: "flows", %name, node_count = graph.nodes.len(), require_approval, "[flows] flows_create: persisting new flow");
let flow =
store::create_flow(config, name, graph, require_approval).map_err(|e| e.to_string())?;
if flow.enabled {
tracing::debug!(target: "flows", flow_id = %flow.id, "[flows] flows_create: flow is enabled — binding automatic-dispatch trigger");
bind_trigger(config, &flow);
}
Ok(RpcOutcome::single_log(flow, "flow created"))
}
/// Loads one flow by id.
pub async fn flows_get(config: &Config, id: &str) -> Result<RpcOutcome<Flow>, String> {
let flow = store::get_flow(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("flow '{id}' not found"))?;
Ok(RpcOutcome::single_log(flow, format!("flow loaded: {id}")))
}
/// Lists every saved flow.
pub async fn flows_list(config: &Config) -> Result<RpcOutcome<Vec<Flow>>, String> {
let flows = store::list_flows(config).map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(flows, "flows listed"))
}
+/// Lists the connection sources a flow node's `connection_ref` can attach to:
+/// Composio connected accounts (`kind = "composio"`) and stored HTTP
+/// credentials (`kind = "http"`). This is the picker source for the Workflows
+/// UI (and the agent's flow-authoring surface) — it returns ids + display
+/// labels + kind ONLY, never any secret material.
+///
+/// The two sources are aggregated independently and are individually
+/// fault-tolerant: a transient Composio backend/network failure (or an
+/// unconfigured Direct-mode key) yields zero Composio entries but still returns
+/// the HTTP credential half, and vice-versa. A failure in one source never
+/// fails the whole picker.
+pub async fn flows_list_connections(
+ config: &Config,
+) -> Result<RpcOutcome<Vec<FlowConnection>>, String> {
+ tracing::debug!(
+ "[flows] rpc flows_list_connections: aggregating composio + http_cred picker sources"
+ );
+ let mut logs = Vec::new();
+
+ // 1. Composio connected accounts. Direct mode without a configured key
+ // already short-circuits to an empty list (a valid setup state, not an
+ // error); a backend outage returns Err — tolerate it so the picker still
+ // surfaces HTTP credentials.
+ let composio_conns =
+ match crate::openhuman::composio::ops::composio_list_connections(config).await {
+ Ok(outcome) => {
+ tracing::debug!(
+ count = outcome.value.connections.len(),
+ "[flows] flows_list_connections: composio source returned connections"
+ );
+ outcome.value.connections
+ }
+ Err(e) => {
+ tracing::warn!(
+ error = %e,
+ "[flows] flows_list_connections: composio source unavailable — \
+ returning http_cred entries only"
+ );
+ logs.push(format!(
+ "flows_list_connections: composio source unavailable ({e})"
+ ));
+ Vec::new()
+ }
+ };
+
+ // 2. Named HTTP credentials — secret-free summaries (the store never hands
+ // out secret material here; injection happens server-side in
+ // `tinyflows::caps::OpenHumanHttp`).
+ let http_creds =
+ match crate::openhuman::credentials::HttpCredentialsStore::from_config(config).list() {
+ Ok(list) => {
+ tracing::debug!(
+ count = list.len(),
+ "[flows] flows_list_connections: http_cred store returned summaries"
+ );
+ list
+ }
+ Err(e) => {
+ tracing::warn!(
+ error = %e,
+ "[flows] flows_list_connections: http_cred store read failed — \
+ returning composio entries only"
+ );
+ logs.push(format!(
+ "flows_list_connections: http_cred store unavailable ({e})"
+ ));
+ Vec::new()
+ }
+ };
+
+ let connections = build_flow_connections(composio_conns, http_creds);
+ tracing::debug!(
+ total = connections.len(),
+ "[flows] flows_list_connections: aggregated picker sources"
+ );
+ logs.push(format!(
+ "flows_list_connections: {} connection(s)",
+ connections.len()
+ ));
+ Ok(RpcOutcome::new(connections, logs))
+}
+
+/// Fold Composio connected accounts + named HTTP credentials into the flat,
+/// secret-free [`FlowConnection`] picker list. Only ACTIVE Composio connections
+/// are surfaced — a pending/expired OAuth account cannot execute a tool, so it
+/// would be a dead pick. Pure (no I/O) so the aggregation shape is
+/// unit-testable without a live backend.
+fn build_flow_connections(
+ composio: Vec<crate::openhuman::composio::ComposioConnection>,
+ http: Vec<crate::openhuman::credentials::HttpCredentialSummary>,
+) -> Vec<FlowConnection> {
+ let mut out = Vec::with_capacity(composio.len() + http.len());
+ for conn in composio {
+ if !conn.is_active() {
+ tracing::debug!(
+ toolkit = %conn.toolkit,
+ connection_id = %conn.id,
+ status = %conn.status,
+ "[flows] flows_list_connections: skipping non-active composio connection"
+ );
+ continue;
+ }
+ let toolkit = conn.normalized_toolkit();
+ out.push(FlowConnection {
+ // Exactly the shape `tinyflows::caps::composio_connection_id` parses.
+ connection_ref: format!("composio:{}:{}", toolkit, conn.id),
+ kind: "composio".to_string(),
+ display: composio_connection_display(&toolkit, &conn),
+ toolkit: Some(toolkit),
+ scheme: None,
+ });
+ }
+ for cred in http {
+ out.push(FlowConnection {
+ // Exactly the shape `tinyflows::caps::http_cred_name` parses.
+ connection_ref: format!("http_cred:{}", cred.name),
+ kind: "http".to_string(),
+ display: http_credential_display(&cred),
+ toolkit: None,
+ scheme: Some(cred.scheme),
+ });
+ }
+ out
+}
+
+/// Human-readable picker label for a Composio connected account, e.g.
+/// `"Gmail · user@example.com"`. Prefers email, then workspace/team, then
+/// handle; falls back to the title-cased toolkit alone when no identity is
+/// cached. The identity fields are display metadata (already surfaced by
+/// `composio_list_connections`), never secret material.
+fn composio_connection_display(
+ toolkit: &str,
+ conn: &crate::openhuman::composio::ComposioConnection,
+) -> String {
+ let title = title_case_toolkit(toolkit);
+ let identity = conn
+ .account_email
+ .as_deref()
+ .or(conn.workspace.as_deref())
+ .or(conn.username.as_deref())
+ .map(str::trim)
+ .filter(|s| !s.is_empty());
+ match identity {
+ Some(id) => format!("{title} · {id}"),
+ None => title,
+ }
+}
+
+/// Human-readable picker label for a named HTTP credential, e.g.
+/// `"stripe (bearer)"`. Only the (non-secret) name + scheme — never the value.
+fn http_credential_display(cred: &crate::openhuman::credentials::HttpCredentialSummary) -> String {
+ format!("{} ({})", cred.name, cred.scheme)
+}
+
+/// Title-case a toolkit slug for display: `"gmail"` → `"Gmail"`,
+/// `"google_calendar"` → `"Google Calendar"`. Best-effort cosmetic only.
+fn title_case_toolkit(toolkit: &str) -> String {
+ let trimmed = toolkit.trim();
+ if trimmed.is_empty() {
+ return String::new();
+ }
+ trimmed
+ .split(|c| c == '_' || c == '-' || c == ' ')
+ .filter(|w| !w.is_empty())
+ .map(|word| {
+ let mut chars = word.chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
+ None => String::new(),
+ }
+ })
+ .collect::<Vec<_>>()
+ .join(" ")
+}
+
/// Updates a flow's name, graph, and/or `require_approval` toggle.
/// Re-validates the graph (whether newly supplied or the existing one)
/// before persisting, same as `flows_create`.
///
/// When the caller supplies a new `graph_json` and the flow is (still)
/// enabled, re-binds the automatic-dispatch trigger if the trigger
/// kind/config actually changed (e.g. a new schedule cron expression) —
/// otherwise the stale binding from the old graph would keep firing on the
/// old cadence, or a newly-added schedule would never get bound at all.
/// Skipped entirely for a name/`require_approval`-only update (no
/// `graph_json` supplied), since the trigger definitely didn't change.
pub async fn flows_update(
config: &Config,
id: &str,
name: Option<String>,
graph_json: Option<Value>,
require_approval: Option<bool>,
) -> Result<RpcOutcome<Flow>, String> {
let existing = store::get_flow(config, id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("flow '{id}' not found"))?;
let new_name = name.unwrap_or_else(|| existing.name.clone());
let new_require_approval = require_approval.unwrap_or(existing.require_approval);
let graph_changed = graph_json.is_some();
let graph = match graph_json {
Some(raw) => validate_and_migrate_graph(raw)?,
None => {
tinyflows::validate::validate(&existing.graph).map_err(|e| e.to_string())?;
existing.graph.clone()
}
};
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_update: persisting changes");
let updated = store::update_flow_graph(config, id, new_name, graph, new_require_approval)
.map_err(|e| e.to_string())?;
if graph_changed && updated.enabled {
let trigger_unchanged = bus::extract_trigger_kind(&existing)
== bus::extract_trigger_kind(&updated)
&& bus::extract_trigger_config(&existing) == bus::extract_trigger_config(&updated);
if !trigger_unchanged {
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_update: trigger changed on an enabled flow — rebinding automatic-dispatch trigger");
unbind_trigger(config, &existing);
bind_trigger(config, &updated);
}
}
Ok(RpcOutcome::single_log(
updated,
format!("flow updated: {id}"),
))
}
/// Deletes a flow by id.
///
/// Unbinds the flow's automatic-dispatch trigger (e.g. the schedule-trigger
/// cron job) *before* removing the flow definition. `flow_runs` cascades on
/// delete via a same-database `FOREIGN KEY ... ON DELETE CASCADE`, but a
/// bound cron job lives in the entirely separate `cron.db` — it does NOT
/// cascade — so skipping this would orphan the cron job, leaving it pointing
/// at a now-nonexistent `flow_id` forever. Best-effort: a lookup failure
/// (flow already gone, store error) is logged and does not block the delete
/// itself — `store::remove_flow` below still errors clearly if `id` doesn't
/// exist.
pub async fn flows_delete(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
match store::get_flow(config, id) {
Ok(Some(flow)) => unbind_trigger(config, &flow),
Ok(None) => {}
Err(e) => {
tracing::warn!(target: "flows", flow_id = %id, error = %e, "[flows] flows_delete: failed to load flow before unbind — proceeding with delete anyway");
}
}
store::remove_flow(config, id).map_err(|e| e.to_string())?;
tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_delete: removed");
Ok(RpcOutcome::new(
json!({ "id": id, "removed": true }),
vec![format!("flow removed: {id}")],
))
diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs
index 47c2bd619..b9506ccea 100644
@@ -1280,80 +1280,223 @@ fn flows_validate_warns_on_unfired_webhook_trigger() {
#[test]
fn flows_validate_does_not_warn_on_schedule_trigger() {
let outcome = flows_validate(schedule_trigger_graph("0 9 * * *"));
assert!(outcome.value.valid);
assert!(
outcome.value.warnings.is_empty(),
"a schedule trigger fires — it must not warn: {:?}",
outcome.value.warnings
);
}
#[test]
fn flows_validate_reports_error_for_graph_without_trigger() {
let graph = json!({
"name": "bad",
"nodes": [ { "id": "a", "kind": "output_parser", "name": "A" } ],
"edges": []
});
let outcome = flows_validate(graph);
assert!(!outcome.value.valid);
assert_eq!(outcome.value.errors.len(), 1);
assert!(outcome.value.errors[0].contains("trigger"));
assert!(
outcome.value.warnings.is_empty(),
"an invalid graph reports no warnings"
);
}
#[tokio::test]
async fn flows_set_enabled_surfaces_unfired_trigger_warning_at_enable() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let created = flows_create(
&config,
"hooked".to_string(),
webhook_trigger_graph(),
false,
)
.await
.unwrap();
// Re-enable (create already enables) to exercise the enable path's warning.
let enabled = flows_set_enabled(&config, &created.value.id, true)
.await
.unwrap();
assert!(enabled.value.enabled);
assert!(
enabled
.logs
.iter()
.any(|l| l.starts_with("warning:") && l.contains("webhook")),
"enabling a webhook-trigger flow must surface a loud warning log, got: {:?}",
enabled.logs
);
}
#[tokio::test]
async fn flows_set_enabled_schedule_flow_has_no_warning() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let created = flows_create(
&config,
"scheduled".to_string(),
schedule_trigger_graph("0 9 * * *"),
false,
)
.await
.unwrap();
let enabled = flows_set_enabled(&config, &created.value.id, true)
.await
.unwrap();
assert!(
!enabled.logs.iter().any(|l| l.starts_with("warning:")),
"a schedule-trigger flow must not surface an unfired-trigger warning: {:?}",
enabled.logs
);
}
+
+// ── flows_list_connections (picker source) ──────────────────────────────
+
+use crate::openhuman::composio::ComposioConnection;
+use crate::openhuman::credentials::{HttpCredential, HttpCredentialSummary, HttpCredentialsStore};
+
+fn composio_conn(id: &str, toolkit: &str, status: &str, email: Option<&str>) -> ComposioConnection {
+ ComposioConnection {
+ id: id.to_string(),
+ toolkit: toolkit.to_string(),
+ status: status.to_string(),
+ created_at: None,
+ account_email: email.map(str::to_string),
+ workspace: None,
+ username: None,
+ }
+}
+
+fn http_summary(name: &str, scheme: &str) -> HttpCredentialSummary {
+ HttpCredentialSummary {
+ name: name.to_string(),
+ scheme: scheme.to_string(),
+ header_name: None,
+ username: None,
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
+ }
+}
+
+#[test]
+fn build_flow_connections_emits_parseable_refs_for_both_kinds() {
+ let composio = vec![composio_conn(
+ "ca_abc",
+ "Gmail",
+ "ACTIVE",
+ Some("user@example.com"),
+ )];
+ let http = vec![http_summary("stripe", "bearer")];
+
+ let out = build_flow_connections(composio, http);
+ assert_eq!(out.len(), 2);
+
+ let gmail = &out[0];
+ assert_eq!(gmail.kind, "composio");
+ // Toolkit is normalized (lowercased) and the ref round-trips through the
+ // exact parser the caps seam uses on execution.
+ assert_eq!(gmail.connection_ref, "composio:gmail:ca_abc");
+ assert_eq!(
+ crate::openhuman::tinyflows::caps::composio_connection_id(&gmail.connection_ref),
+ Some("ca_abc")
+ );
+ assert_eq!(gmail.toolkit.as_deref(), Some("gmail"));
+ assert_eq!(gmail.display, "Gmail · user@example.com");
+ assert!(gmail.scheme.is_none());
+
+ let stripe = &out[1];
+ assert_eq!(stripe.kind, "http");
+ assert_eq!(stripe.connection_ref, "http_cred:stripe");
+ assert_eq!(
+ crate::openhuman::tinyflows::caps::http_cred_name(&stripe.connection_ref),
+ Some("stripe")
+ );
+ assert_eq!(stripe.scheme.as_deref(), Some("bearer"));
+ assert_eq!(stripe.display, "stripe (bearer)");
+ assert!(stripe.toolkit.is_none());
+}
+
+#[test]
+fn build_flow_connections_skips_non_active_composio_accounts() {
+ let composio = vec![
+ composio_conn("ca_ok", "notion", "ACTIVE", None),
+ composio_conn("ca_pending", "slack", "PENDING", None),
+ ];
+ let out = build_flow_connections(composio, Vec::new());
+ assert_eq!(out.len(), 1, "only the ACTIVE connection is surfaced");
+ assert_eq!(out[0].connection_ref, "composio:notion:ca_ok");
+ // No cached identity → title-cased toolkit alone.
+ assert_eq!(out[0].display, "Notion");
+}
+
+#[test]
+fn build_flow_connections_never_carries_secret_fields() {
+ let out = build_flow_connections(
+ vec![composio_conn("ca_abc", "gmail", "ACTIVE", Some("u@x.io"))],
+ vec![http_summary("stripe", "header")],
+ );
+ let json = serde_json::to_string(&out).unwrap();
+ // The serialized picker payload must expose only ref/kind/display/toolkit/
+ // scheme — no secret-bearing key names at all.
+ for banned in [
+ "secret", "token", "password", "\"key\"", "apiKey", "api_key",
+ ] {
+ assert!(
+ !json
+ .to_ascii_lowercase()
+ .contains(&banned.to_ascii_lowercase()),
+ "serialized FlowConnection leaked a secret-bearing field ({banned}): {json}"
+ );
+ }
+}
+
+#[test]
+fn title_case_toolkit_handles_underscores_and_dashes() {
+ assert_eq!(title_case_toolkit("gmail"), "Gmail");
+ assert_eq!(title_case_toolkit("google_calendar"), "Google Calendar");
+ assert_eq!(title_case_toolkit("google-sheets"), "Google Sheets");
+ assert_eq!(title_case_toolkit(""), "");
+}
+
+#[tokio::test]
+async fn flows_list_connections_aggregates_http_creds_and_tolerates_composio() {
+ let tmp = TempDir::new().unwrap();
+ let mut config = test_config(&tmp);
+ // Force Direct mode with no key so the composio source short-circuits to an
+ // empty list offline (no network) — proving the aggregation still returns
+ // the HTTP-credential half.
+ config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_DIRECT.to_string();
+ // Secrets in the clear at rest for the test (mirrors the E2E config).
+ config.secrets.encrypt = false;
+
+ // Seed one HTTP credential through the same store the op reads.
+ let store = HttpCredentialsStore::from_config(&config);
+ store
+ .upsert(&HttpCredential::bearer("stripe", "sk_live_seed_secret"))
+ .unwrap();
+
+ let outcome = flows_list_connections(&config).await.unwrap();
+ let refs: Vec<_> = outcome
+ .value
+ .iter()
+ .map(|c| c.connection_ref.as_str())
+ .collect();
+ assert!(
+ refs.contains(&"http_cred:stripe"),
+ "http_cred must be surfaced: {refs:?}"
+ );
+
+ // The secret must never appear anywhere in the RPC payload.
+ let json = serde_json::to_string(&outcome.value).unwrap();
+ assert!(
+ !json.contains("sk_live_seed_secret"),
+ "secret leaked into flows_list_connections payload: {json}"
+ );
+}
diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs
index 62b3b945c..ca896db1b 100644
@@ -1,294 +1,360 @@
//! RPC/CLI controller surface for the `flows::` domain. Mirrors
//! `src/openhuman/cron/schemas.rs`'s shape exactly: `schemas(function)` builds
//! one `ControllerSchema`, `all_controller_schemas()`/
//! `all_registered_controllers()` aggregate them, and each `handle_*` loads
//! config, reads params, awaits the matching `ops::flows_*` fn, and converts
//! the `RpcOutcome` to CLI-compatible JSON.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::flows::ops;
use crate::rpc::RpcOutcome;
fn id_input(comment: &'static str) -> FieldSchema {
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment,
required: true,
}
}
fn flow_output() -> FieldSchema {
FieldSchema {
name: "flow",
ty: TypeSchema::Ref("Flow"),
comment: "The flow definition.",
required: true,
}
}
fn require_approval_input() -> FieldSchema {
FieldSchema {
name: "require_approval",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "Force a human-approval gate on every outbound tool/HTTP action this flow \
takes, regardless of its saved-flow trust root. Defaults to `false`.",
required: false,
}
}
fn run_output_fields() -> Vec<FieldSchema> {
vec![
FieldSchema {
name: "output",
ty: TypeSchema::Json,
comment: "The run's final state (per-node items, trigger payload).",
required: true,
},
FieldSchema {
name: "pending_approvals",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Node ids paused awaiting human approval; empty once completed.",
required: true,
},
FieldSchema {
name: "thread_id",
ty: TypeSchema::String,
comment: "Durable checkpoint thread id for this run (needed to resume).",
required: true,
},
]
}
+/// Field schema for one `FlowConnection` element of `flows_list_connections`'s
+/// output. Kept in one place so the schema mirrors
+/// `flows::types::FlowConnection` exactly — and documents that no secret field
+/// exists on the wire.
+fn flow_connection_fields() -> Vec<FieldSchema> {
+ vec![
+ FieldSchema {
+ name: "connection_ref",
+ ty: TypeSchema::String,
+ comment: "Ready-to-use `connection_ref` to stamp onto a node: \
+ `composio:<toolkit>:<connection_id>` or `http_cred:<name>`.",
+ required: true,
+ },
+ FieldSchema {
+ name: "kind",
+ ty: TypeSchema::String,
+ comment: "Source kind: `composio` | `http`.",
+ required: true,
+ },
+ FieldSchema {
+ name: "display",
+ ty: TypeSchema::String,
+ comment: "Human-readable picker label (e.g. `Gmail · user@example.com`). \
+ Never secret material.",
+ required: true,
+ },
+ FieldSchema {
+ name: "toolkit",
+ ty: TypeSchema::Option(Box::new(TypeSchema::String)),
+ comment: "Composio toolkit slug (kind `composio` only).",
+ required: false,
+ },
+ FieldSchema {
+ name: "scheme",
+ ty: TypeSchema::Option(Box::new(TypeSchema::String)),
+ comment: "HTTP credential injection scheme (kind `http` only): \
+ `bearer` | `basic` | `header`.",
+ required: false,
+ },
+ ]
+}
+
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("create"),
schemas("validate"),
schemas("get"),
schemas("list"),
+ schemas("list_connections"),
schemas("update"),
schemas("delete"),
schemas("set_enabled"),
schemas("run"),
schemas("resume"),
schemas("cancel_run"),
schemas("list_runs"),
schemas("get_run"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("create"),
handler: handle_create,
},
RegisteredController {
schema: schemas("validate"),
handler: handle_validate,
},
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
+ RegisteredController {
+ schema: schemas("list_connections"),
+ handler: handle_list_connections,
+ },
RegisteredController {
schema: schemas("update"),
handler: handle_update,
},
RegisteredController {
schema: schemas("delete"),
handler: handle_delete,
},
RegisteredController {
schema: schemas("set_enabled"),
handler: handle_set_enabled,
},
RegisteredController {
schema: schemas("run"),
handler: handle_run,
},
RegisteredController {
schema: schemas("resume"),
handler: handle_resume,
},
RegisteredController {
schema: schemas("cancel_run"),
handler: handle_cancel_run,
},
RegisteredController {
schema: schemas("list_runs"),
handler: handle_list_runs,
},
RegisteredController {
schema: schemas("get_run"),
handler: handle_get_run,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"create" => ControllerSchema {
namespace: "flows",
function: "create",
description: "Create a new saved automation workflow from a tinyflows graph.",
inputs: vec![
FieldSchema {
name: "name",
ty: TypeSchema::String,
comment: "Human-readable flow name.",
required: true,
},
FieldSchema {
name: "graph",
ty: TypeSchema::Json,
comment:
"A tinyflows WorkflowGraph (nodes + edges); validated and migrated on save.",
required: true,
},
require_approval_input(),
],
outputs: vec![flow_output()],
},
"validate" => ControllerSchema {
namespace: "flows",
function: "validate",
description: "Validate a tinyflows graph without saving it: reports structural \
validity plus non-fatal warnings (e.g. a trigger kind that does not \
fire automatically yet).",
inputs: vec![FieldSchema {
name: "graph",
ty: TypeSchema::Json,
comment: "A tinyflows WorkflowGraph (nodes + edges) to validate and migrate.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "valid",
ty: TypeSchema::Bool,
comment: "True when the graph is structurally valid.",
required: true,
},
FieldSchema {
name: "errors",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Structural validation errors; empty when `valid`.",
required: true,
},
FieldSchema {
name: "warnings",
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment: "Non-fatal warnings (e.g. an unfired trigger kind); the graph is \
still saveable/enable-able.",
required: true,
},
],
},
"get" => ControllerSchema {
namespace: "flows",
function: "get",
description: "Load one saved flow by id.",
inputs: vec![id_input("Identifier of the flow to load.")],
outputs: vec![flow_output()],
},
"list" => ControllerSchema {
namespace: "flows",
function: "list",
description: "List all saved flows.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "flows",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Flow"))),
comment: "Flows currently stored in the workspace.",
required: true,
}],
},
+ "list_connections" => ControllerSchema {
+ namespace: "flows",
+ function: "list_connections",
+ description: "List the connection sources a flow node's `connection_ref` can attach \
+ to: Composio connected accounts (kind `composio`) and stored HTTP \
+ credentials (kind `http`). Returns ids + display labels + kind ONLY — \
+ never any secret material (OAuth/bearer tokens, passwords, and API \
+ keys stay server-side and are injected only at execution time).",
+ inputs: vec![],
+ outputs: vec![FieldSchema {
+ name: "connections",
+ ty: TypeSchema::Array(Box::new(TypeSchema::Object {
+ fields: flow_connection_fields(),
+ })),
+ comment: "Resolvable connections for the flows picker (composio + http), \
+ secret-free.",
+ required: true,
+ }],
+ },
"update" => ControllerSchema {
namespace: "flows",
function: "update",
description: "Update a saved flow's name and/or graph; re-validates before persisting.",
inputs: vec![
id_input("Identifier of the flow to update."),
FieldSchema {
name: "name",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "New name, if changing it.",
required: false,
},
FieldSchema {
name: "graph",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Replacement WorkflowGraph, if changing it.",
required: false,
},
require_approval_input(),
],
outputs: vec![flow_output()],
},
"delete" => ControllerSchema {
namespace: "flows",
function: "delete",
description: "Delete a saved flow by id.",
inputs: vec![id_input("Identifier of the flow to delete.")],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Identifier that was requested for removal.",
required: true,
},
FieldSchema {
name: "removed",
ty: TypeSchema::Bool,
comment: "True when the flow was removed.",
required: true,
},
],
},
comment: "Removal result payload.",
required: true,
}],
},
"set_enabled" => ControllerSchema {
namespace: "flows",
function: "set_enabled",
description: "Enable or disable a saved flow.",
inputs: vec![
id_input("Identifier of the flow to toggle."),
FieldSchema {
name: "enabled",
ty: TypeSchema::Bool,
comment: "New enabled state.",
required: true,
},
],
outputs: vec![flow_output()],
},
"run" => ControllerSchema {
namespace: "flows",
function: "run",
description:
"Run a saved flow to completion (or until it pauses on a human-approval gate).",
inputs: vec![
id_input("Identifier of the flow to run."),
FieldSchema {
name: "input",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Trigger payload seeded into the run; defaults to null.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
@@ -399,332 +465,376 @@ pub fn schemas(function: &str) -> ControllerSchema {
outputs: vec![FieldSchema {
name: "runs",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("FlowRun"))),
comment: "Persisted run records for this flow, newest first.",
required: true,
}],
},
"get_run" => ControllerSchema {
namespace: "flows",
function: "get_run",
description: "Load one persisted flow run record by its (checkpoint thread) id.",
inputs: vec![FieldSchema {
name: "run_id",
ty: TypeSchema::String,
comment: "Identifier of the run to load (== its checkpoint thread id).",
required: true,
}],
outputs: vec![FieldSchema {
name: "run",
ty: TypeSchema::Ref("FlowRun"),
comment: "The persisted run record.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "flows",
function: "unknown",
description: "Unknown flows controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_create(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let name = read_required::<String>(¶ms, "name")?;
let graph = read_required::<Value>(¶ms, "graph")?;
let require_approval = params
.get("require_approval")
.and_then(Value::as_bool)
.unwrap_or(false);
to_json(ops::flows_create(&config, name, graph, require_approval).await?)
})
}
fn handle_validate(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
// No config load: validation is pure (no persistence, no workspace).
let graph = read_required::<Value>(¶ms, "graph")?;
to_json(ops::flows_validate(graph))
})
}
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
to_json(ops::flows_get(&config, id.trim()).await?)
})
}
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::flows_list(&config).await?)
})
}
+fn handle_list_connections(_params: Map<String, Value>) -> ControllerFuture {
+ Box::pin(async move {
+ let config = config_rpc::load_config_with_timeout().await?;
+ to_json(ops::flows_list_connections(&config).await?)
+ })
+}
+
fn handle_update(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
let name = params
.get("name")
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(|e| format!("invalid 'name': {e}"))?;
let graph = params.get("graph").filter(|v| !v.is_null()).cloned();
let require_approval = params.get("require_approval").and_then(Value::as_bool);
to_json(ops::flows_update(&config, id.trim(), name, graph, require_approval).await?)
})
}
fn handle_delete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
to_json(ops::flows_delete(&config, id.trim()).await?)
})
}
fn handle_set_enabled(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
let enabled = params
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| "missing required param 'enabled'".to_string())?;
to_json(ops::flows_set_enabled(&config, id.trim(), enabled).await?)
})
}
fn handle_run(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
let input = params.get("input").cloned().unwrap_or(Value::Null);
to_json(
ops::flows_run(
&config,
id.trim(),
input,
crate::openhuman::flows::FlowRunTrigger::Rpc,
)
.await?,
)
})
}
fn handle_resume(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
let thread_id = read_required::<String>(¶ms, "thread_id")?;
let approvals: Vec<String> = params
.get("approvals")
.filter(|v| !v.is_null())
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| format!("invalid 'approvals': {e}"))?
.unwrap_or_default();
let rejections: Vec<String> = params
.get("rejections")
.filter(|v| !v.is_null())
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| format!("invalid 'rejections': {e}"))?
.unwrap_or_default();
to_json(
ops::flows_resume(&config, id.trim(), thread_id.trim(), approvals, rejections).await?,
)
})
}
fn handle_cancel_run(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let run_id = read_required::<String>(¶ms, "run_id")?;
to_json(ops::flows_cancel_run(&config, run_id.trim()).await?)
})
}
fn handle_list_runs(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let id = read_required::<String>(¶ms, "id")?;
let limit = params
.get("limit")
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(20);
to_json(ops::flows_list_runs(&config, id.trim(), limit).await?)
})
}
fn handle_get_run(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let run_id = read_required::<String>(¶ms, "run_id")?;
to_json(ops::flows_get_run(&config, run_id.trim()).await?)
})
}
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
let value = params
.get(key)
.cloned()
.ok_or_else(|| format!("missing required param '{key}'"))?;
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_controller_schemas_covers_every_supported_function() {
let names: Vec<_> = all_controller_schemas()
.into_iter()
.map(|s| s.function)
.collect();
assert_eq!(
names,
vec![
"create",
"validate",
"get",
"list",
+ "list_connections",
"update",
"delete",
"set_enabled",
"run",
"resume",
"cancel_run",
"list_runs",
"get_run",
]
);
}
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
- assert_eq!(controllers.len(), 12);
+ assert_eq!(controllers.len(), 13);
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
assert_eq!(
names,
vec![
"create",
"validate",
"get",
"list",
+ "list_connections",
"update",
"delete",
"set_enabled",
"run",
"resume",
"cancel_run",
"list_runs",
"get_run",
]
);
}
+ #[test]
+ fn schemas_list_connections_has_no_inputs_and_secret_free_outputs() {
+ let s = schemas("list_connections");
+ assert_eq!(s.namespace, "flows");
+ assert!(s.inputs.is_empty());
+ // The only output is the `connections` array.
+ assert_eq!(s.outputs.len(), 1);
+ assert_eq!(s.outputs[0].name, "connections");
+ // No field on a FlowConnection element may resemble secret material.
+ if let TypeSchema::Array(inner) = &s.outputs[0].ty {
+ if let TypeSchema::Object { fields } = inner.as_ref() {
+ let names: Vec<_> = fields.iter().map(|f| f.name).collect();
+ assert_eq!(
+ names,
+ vec!["connection_ref", "kind", "display", "toolkit", "scheme"]
+ );
+ for f in fields {
+ let n = f.name.to_ascii_lowercase();
+ assert!(
+ !n.contains("secret")
+ && !n.contains("token")
+ && !n.contains("password")
+ && !n.contains("key"),
+ "flow_connection field '{}' looks secret-bearing",
+ f.name
+ );
+ }
+ } else {
+ panic!("connections element type is not an Object");
+ }
+ } else {
+ panic!("connections output is not an Array");
+ }
+ }
+
#[test]
fn schemas_create_requires_name_and_graph() {
let s = schemas("create");
assert_eq!(s.namespace, "flows");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["name", "graph"]);
}
#[test]
fn schemas_create_require_approval_is_optional() {
let s = schemas("create");
let field = s
.inputs
.iter()
.find(|f| f.name == "require_approval")
.unwrap();
assert!(!field.required);
}
#[test]
fn schemas_run_input_is_optional() {
let s = schemas("run");
let input = s.inputs.iter().find(|f| f.name == "input").unwrap();
assert!(!input.required);
}
#[test]
fn schemas_resume_requires_id_and_thread_id_but_not_approvals() {
let s = schemas("resume");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["id", "thread_id"]);
let approvals = s.inputs.iter().find(|f| f.name == "approvals").unwrap();
assert!(!approvals.required);
}
#[test]
fn schemas_list_runs_limit_is_optional() {
let s = schemas("list_runs");
let limit = s.inputs.iter().find(|f| f.name == "limit").unwrap();
assert!(!limit.required);
}
#[test]
fn schemas_get_run_requires_run_id() {
let s = schemas("get_run");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["run_id"]);
}
#[test]
fn schemas_unknown_function_returns_placeholder() {
let s = schemas("does-not-exist");
assert_eq!(s.function, "unknown");
assert_eq!(s.outputs[0].name, "error");
}
#[test]
fn read_required_errors_when_missing() {
let params = Map::new();
let err = read_required::<String>(¶ms, "id").unwrap_err();
assert!(err.contains("missing required param 'id'"));
}
}
diff --git a/src/openhuman/flows/types.rs b/src/openhuman/flows/types.rs
index 1702f4eb8..32adfbd0a 100644
@@ -44,160 +44,196 @@ impl FlowRunTrigger {
/// migration; `errors` carries the single structural error when it does not.
/// `warnings` is orthogonal to validity — a `valid` graph can still carry
/// warnings (it saves and enables fine, it just won't behave as an author
/// might expect), and an invalid graph reports no warnings (there's nothing to
/// warn about a graph that won't compile).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FlowValidation {
/// True when the graph is structurally valid (migrates + validates).
pub valid: bool,
/// Structural validation errors (empty when `valid`). Today at most one —
/// `tinyflows::validate::validate` returns the first error it hits.
pub errors: Vec<String>,
/// Non-fatal warnings: the graph is accepted, but something about it is
/// worth flagging (e.g. an unfired trigger kind). Never blocks save/enable.
pub warnings: Vec<String>,
}
/// A saved automation workflow: a `tinyflows` graph plus OpenHuman-side
/// bookkeeping (enablement, run history summary).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flow {
/// Stable identifier (UUID) for this flow.
pub id: String,
/// Human-readable name shown in the Workflows UI.
pub name: String,
/// Whether this flow may currently be triggered (B2) / run.
pub enabled: bool,
/// The validated, migrated workflow graph.
pub graph: WorkflowGraph,
/// RFC3339 creation timestamp.
pub created_at: String,
/// RFC3339 last-update timestamp.
pub updated_at: String,
/// RFC3339 timestamp of the most recent run, if any.
pub last_run_at: Option<String>,
/// Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`.
pub last_status: Option<String>,
/// "Require approval for outbound actions" (issue B2). When `true`, the
/// approval gate does NOT auto-allow this flow's `TrustedAutomation
/// { Workflow }` trust root — every external_effect tool/HTTP call the
/// flow makes still parks for a real decision, regardless of how the run
/// was triggered. See `src/openhuman/approval/gate.rs` and
/// `src/openhuman/agent/turn_origin.rs::TrustedAutomationSource::Workflow`.
#[serde(default)]
pub require_approval: bool,
}
/// One step of a persisted [`FlowRun`] (run-history inspector).
///
/// As of issue G2 (live run observation) these are persisted **incrementally**
/// as each non-trigger node finishes, by
/// `flows::observability::FlowRunObserver::on_step_finish`, which maps a live
/// `tinyflows::observability::ExecutionStep` (carrying real `status` +
/// `duration_ms`) onto this type. The prior post-hoc reconstruction from
/// `RunOutcome.output["nodes"]` (see `flows::ops::reconstruct_steps`) now only
/// fills in steps the observer missed (e.g. a trigger node, which does not
/// emit an `on_step_finish`) — those carry no `status`/`duration_ms` and keep
/// the `port` the reconstruction recovers.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct FlowRunStep {
/// The node's id within the flow's graph.
pub node_id: String,
/// The node's emitted items for this run (`output["nodes"][id]["items"]`,
/// or the live `ExecutionStep.output` when observed incrementally).
pub output: serde_json::Value,
/// The output port the node routed on, if it picked one (branching /
/// switch nodes) — `output["nodes"][id]["port"]`. Only recovered by the
/// post-hoc reconstruction; the live observer does not carry a port.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<String>,
/// Live step outcome, when this step was observed incrementally:
/// `"success"` | `"error"`. `None` for a step recovered post-hoc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// Wall-clock duration of the node's executor in milliseconds, when
/// observed incrementally. `None` for a step recovered post-hoc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
+/// A resolvable connection the flows UI / agent picker can attach to a node's
+/// `connection_ref`. Aggregated by `openhuman.flows_list_connections` from two
+/// host-side sources:
+///
+/// - **Composio connected accounts** (`kind = "composio"`) — each active OAuth
+/// integration instance, emitted as a ready-to-use
+/// `"composio:<toolkit>:<connection_id>"` ref (the exact shape
+/// `tinyflows::caps::composio_connection_id` parses back on execution).
+/// - **Named HTTP credentials** (`kind = "http"`) — each stored injection
+/// template, emitted as `"http_cred:<name>"` (the shape
+/// `tinyflows::caps::http_cred_name` parses).
+///
+/// **Security contract:** carries only non-secret identity — the
+/// `connection_ref` string plus a display label (and toolkit/scheme hints).
+/// It NEVER carries secret material (OAuth tokens, bearer tokens, passwords,
+/// API keys). Those stay server-side and are injected only inside the
+/// `tinyflows::caps` adapters at execution time.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct FlowConnection {
+ /// The ready-to-use `connection_ref` value to stamp onto a node:
+ /// `"composio:<toolkit>:<connection_id>"` or `"http_cred:<name>"`.
+ pub connection_ref: String,
+ /// Source kind: `"composio"` | `"http"`.
+ pub kind: String,
+ /// Human-readable label for the picker, e.g. `"Gmail · user@example.com"`
+ /// or `"stripe (bearer)"`. Never contains secret material.
+ pub display: String,
+ /// Composio toolkit slug (`kind = "composio"` only), e.g. `"gmail"`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub toolkit: Option<String>,
+ /// HTTP credential injection scheme (`kind = "http"` only):
+ /// `"bearer"` | `"basic"` | `"header"`. Not a secret.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub scheme: Option<String>,
+}
+
/// A persisted record of one `flows_run` / `flows_resume` invocation, for the
/// B3 run-history inspector. Written by `flows::store` from `flows::ops`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowRun {
/// Stable identifier for this run — the same value as `thread_id` (the
/// tinyflows checkpointer key), so a run row can be found either way.
pub id: String,
/// The flow this run belongs to.
pub flow_id: String,
/// The tinyflows checkpointer thread id (needed to `flows_resume`).
pub thread_id: String,
/// Run status. Not an enum (kept a free-form `String` for forward-compat
/// with statuses added by newer builds), but the vocabulary is fixed:
/// `"running"` | `"completed"` | `"pending_approval"` | `"failed"` |
/// `"cancelled"` (issue G4 — a run cancelled via `flows_cancel_run`, or a
/// parked `pending_approval` run swept by the TTL expiry). All of
/// `completed` / `failed` / `cancelled` are terminal.
pub status: String,
/// RFC3339 timestamp when the run started.
pub started_at: String,
/// RFC3339 timestamp when the run last settled (completed/paused/failed).
/// `None` while a run row is still `"running"`.
pub finished_at: Option<String>,
/// Reconstructed per-node steps (see [`FlowRunStep`]).
#[serde(default)]
pub steps: Vec<FlowRunStep>,
/// Node ids paused awaiting human approval when `status ==
/// "pending_approval"`; empty otherwise.
#[serde(default)]
pub pending_approvals: Vec<String>,
/// Error message when `status == "failed"`.
#[serde(default)]
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use tinyflows::model::{Node, NodeKind};
fn sample_graph() -> WorkflowGraph {
WorkflowGraph {
nodes: vec![Node {
id: "t".to_string(),
kind: NodeKind::Trigger,
type_version: 1,
name: "Trigger".to_string(),
config: serde_json::Value::Null,
ports: Vec::new(),
position: None,
}],
..Default::default()
}
}
#[test]
fn flow_round_trips_through_json() {
let flow = Flow {
id: "flow_1".to_string(),
name: "demo".to_string(),
enabled: true,
graph: sample_graph(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
last_run_at: None,
last_status: None,
require_approval: false,
};
let json = serde_json::to_string(&flow).expect("serialize");
let back: Flow = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, flow.id);
assert_eq!(back.graph, flow.graph);
assert!(back.last_run_at.is_none());
assert!(!back.require_approval);
}
#[test]
fn flow_require_approval_defaults_false_when_omitted_from_json() {
// Legacy/serialized JSON authored before the field existed must still
// deserialize (SQLite rows are migrated via `add_column_if_missing`,
diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs
index 4781802da..0e3181575 100644
@@ -1,693 +1,1594 @@
//! The capability seam: five adapters implementing `tinyflows::caps` traits
//! over real OpenHuman services.
//!
//! Each tinyflows integration node hands its **whole** `node.config` to the
//! matching trait method — the adapter interprets a free-form JSON value the
//! flow author wrote, pulling a connection ref out of `config["connection_ref"]`
//! where relevant. See `my_docs/ohxtf/b1-engine-seam-domain/04-capability-seam.md`
//! for the source-verified node → trait contract this mirrors.
//!
//! All host errors are mapped to `tinyflows::error::EngineError::Capability`,
//! per the crate's contract (`caps` traits return `tinyflows::error::Result`).
use std::sync::Arc;
use anyhow::Context;
use async_trait::async_trait;
use serde_json::{json, Value};
use tinyagents::graph::SqliteCheckpointer;
use tinyflows::caps::{
Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore, ToolInvoker,
};
use tinyflows::error::{EngineError, Result};
use crate::openhuman::agent::harness::definition::SandboxMode;
use crate::openhuman::composio::client::{
create_composio_client, direct_execute, ComposioClientKind,
};
use crate::openhuman::config::{Config, HttpRequestConfig};
+use crate::openhuman::credentials::{HttpCredential, HttpCredentialsStore};
use crate::openhuman::flows;
use crate::openhuman::inference::provider::{
create_chat_provider, ChatMessage, ChatRequest, UsageInfo,
};
use crate::openhuman::sandbox::{execute_in_sandbox, resolve_sandbox_policy};
-use crate::openhuman::security::SecurityPolicy;
+use crate::openhuman::security::{
+ CommandClass, GateDecision, SecurityPolicy, POLICY_BLOCKED_MARKER,
+};
use crate::openhuman::tools::traits::Tool as _;
use crate::openhuman::tools::HttpRequestTool;
/// Maps a `UsageInfo` (not `Serialize`) into a JSON value field-by-field, so
/// [`OpenHumanLlm::complete`] can surface it in its response `Value` without
/// requiring an upstream `Serialize` impl change.
fn usage_to_json(usage: &Option<UsageInfo>) -> Value {
match usage {
None => Value::Null,
Some(u) => json!({
"input_tokens": u.input_tokens,
"output_tokens": u.output_tokens,
"context_window": u.context_window,
"cached_input_tokens": u.cached_input_tokens,
"cache_creation_tokens": u.cache_creation_tokens,
"reasoning_tokens": u.reasoning_tokens,
"charged_amount_usd": u.charged_amount_usd,
}),
}
}
+/// Hard autonomy-tier gate for an *acting* flow node (Phase 2).
+///
+/// A flow run scopes a `TrustedAutomation { Workflow }` origin, but the acting
+/// power of a run is still bounded by the user's `[autonomy]` tier — the same
+/// [`SecurityPolicy`] the agent tool-loop honors (`SecurityPolicy::from_config`
+/// off the `[autonomy]` block). Before an `http_request` (Network-class) or
+/// `code` (Write-class) node dispatches, we consult
+/// [`SecurityPolicy::gate_decision`] for that node's [`CommandClass`] and refuse
+/// outright when the tier `Block`s it — mirroring how `curl`/`shell` acting
+/// tools gate (`policy.gate_decision(CommandClass::Network)`), so a read-only
+/// run can never reach the network or run arbitrary code.
+///
+/// `Allow`/`Prompt` return `Ok(decision)`: this function only enforces the
+/// non-negotiable `Block` floor itself. The caller uses the returned
+/// [`GateDecision`] to drive [`gate_call_for_tier`] immediately after, which is
+/// what actually performs the `Prompt` round-trip (see that function's doc for
+/// why this is not automatic — a saved workflow's own `require_approval` flag
+/// would otherwise silently override the tier's `Prompt` decision). The error
+/// is prefixed with [`POLICY_BLOCKED_MARKER`] so the harness's repeated-failure
+/// middleware recognizes it as a permanent, don't-retry refusal.
+fn enforce_node_tier_gate(
+ security: &SecurityPolicy,
+ class: CommandClass,
+ node: &str,
+) -> Result<GateDecision> {
+ let decision = security.gate_decision(class);
+ tracing::debug!(
+ target: "flows",
+ node,
+ ?class,
+ ?decision,
+ tier = ?security.autonomy,
+ "[flows] node tier gate: evaluating autonomy-tier decision"
+ );
+ if decision == GateDecision::Block {
+ tracing::warn!(
+ target: "flows",
+ node,
+ ?class,
+ tier = ?security.autonomy,
+ "[flows] node tier gate: BLOCKED by autonomy tier — refusing before dispatch"
+ );
+ return Err(EngineError::Capability(format!(
+ "{POLICY_BLOCKED_MARKER} flows {node} node is not permitted under the current \
+ autonomy tier ({:?}): {class:?}-class actions are blocked. Raise the [autonomy] \
+ tier to run this node.",
+ security.autonomy
+ )));
+ }
+ Ok(decision)
+}
+
+/// Dispatches to the process-global [`ApprovalGate`](crate::openhuman::approval::ApprovalGate),
+/// escalating a `Prompt`-tier decision into a forced human-in-the-loop round
+/// trip regardless of the running flow's own `require_approval` toggle.
+///
+/// **Why this is needed (Codex P1 finding):** `ApprovalGate::intercept_audited`
+/// branches on the scoped [`AgentTurnOrigin`](crate::openhuman::agent::turn_origin::AgentTurnOrigin) —
+/// for a `TrustedAutomation { source: Workflow { require_approval: false }, .. }`
+/// origin (the default for every saved flow unless the author opts in) it
+/// returns `Allow` unconditionally, the same pre-declared-trust-root shortcut a
+/// user-authorized cron job gets. That shortcut is correct when the node's
+/// autonomy-tier decision was itself `Allow`, but it silently defeats a
+/// Supervised-tier `Prompt` decision: without this escalation, a Supervised
+/// user's `http_request`/`code` node would run unattended purely because the
+/// flow's `require_approval` defaults to `false` — the tier's "ask me" was
+/// never actually enforced.
+///
+/// When `tier_decision` is [`GateDecision::Prompt`] and the current origin is a
+/// `Workflow { require_approval: false }` trust root, this scopes a *for this
+/// call only* `Workflow { require_approval: true }` origin around
+/// `intercept_audited`, forcing the real parking/HITL flow. `GateDecision::Allow`
+/// (and any other origin shape) passes through unchanged — existing behavior.
+async fn gate_call_for_tier(
+ tier_decision: GateDecision,
+ tool_name: &str,
+ action_summary: &str,
+ args_redacted: Value,
+) -> (crate::openhuman::approval::GateOutcome, Option<String>) {
+ use crate::openhuman::agent::turn_origin;
+
+ let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() else {
+ return (crate::openhuman::approval::GateOutcome::Allow, None);
+ };
+
+ match escalated_origin_for_prompt(tier_decision, turn_origin::current()) {
+ Some(escalated) => {
+ tracing::debug!(
+ target: "flows",
+ tool_name,
+ "[flows] node tier gate: tier decision is Prompt — escalating this dispatch to a \
+ forced approval round-trip regardless of the flow's require_approval toggle"
+ );
+ turn_origin::with_origin(
+ escalated,
+ gate.intercept_audited(tool_name, action_summary, args_redacted),
+ )
+ .await
+ }
+ None => {
+ gate.intercept_audited(tool_name, action_summary, args_redacted)
+ .await
+ }
+ }
+}
+
+/// Pure decision core of [`gate_call_for_tier`]: when `tier_decision` is
+/// [`GateDecision::Prompt`] and `origin` is a `Workflow { require_approval:
+/// false }` trust root, returns a clone of that origin with `require_approval`
+/// flipped to `true` (the forced escalation). Otherwise returns `None` — the
+/// caller then dispatches through the unmodified origin, matching prior
+/// behavior. Split out as a free function over plain values (no gate, no
+/// task-local read) so the escalation policy is unit-testable without a live
+/// `ApprovalGate`.
+fn escalated_origin_for_prompt(
+ tier_decision: GateDecision,
+ origin: Option<crate::openhuman::agent::turn_origin::AgentTurnOrigin>,
+) -> Option<crate::openhuman::agent::turn_origin::AgentTurnOrigin> {
+ use crate::openhuman::agent::turn_origin::{AgentTurnOrigin, TrustedAutomationSource};
+
+ if tier_decision != GateDecision::Prompt {
+ return None;
+ }
+ match origin {
+ Some(AgentTurnOrigin::TrustedAutomation {
+ job_id,
+ source:
+ TrustedAutomationSource::Workflow {
+ require_approval: false,
+ },
+ }) => Some(AgentTurnOrigin::TrustedAutomation {
+ job_id,
+ source: TrustedAutomationSource::Workflow {
+ require_approval: true,
+ },
+ }),
+ _ => None,
+ }
+}
+
/// [`LlmProvider`] adapter over OpenHuman's inference stack
/// (`src/openhuman/inference/provider/`).
///
/// The `agent` node is single-completion in tinyflows 0.2 (no tool-calling
/// loop, no sub-ports), so `complete` performs exactly one `provider.chat`
/// call and returns its result — no agent loop is driven here.
pub struct OpenHumanLlm {
pub config: Arc<Config>,
}
#[async_trait]
impl LlmProvider for OpenHumanLlm {
async fn complete(&self, request: Value, conn: Option<&str>) -> Result<Value> {
if let Some(c) = conn {
// B1 does not resolve `connection_ref` to a specific BYOK account —
// `create_chat_provider` picks the configured provider for `role`.
tracing::debug!(target: "flows", conn = %c, "[flows] llm conn (not resolved in B1)");
}
let role = request
.get("role")
.and_then(Value::as_str)
.unwrap_or("summarization");
let temperature = request
.get("temperature")
.and_then(Value::as_f64)
.unwrap_or(0.7);
let max_tokens = request
.get("max_tokens")
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok());
let messages: Vec<ChatMessage> = match request.get("messages").and_then(Value::as_array) {
Some(entries) if !entries.is_empty() => entries
.iter()
.filter_map(|entry| {
let content = entry.get("content").and_then(Value::as_str)?.to_string();
let role = entry.get("role").and_then(Value::as_str).unwrap_or("user");
Some(match role {
"system" => ChatMessage::system(content),
"assistant" => ChatMessage::assistant(content),
"tool" => ChatMessage::tool(content),
_ => ChatMessage::user(content),
})
})
.collect(),
_ => {
let prompt = request
.get("prompt")
.and_then(Value::as_str)
.unwrap_or_default();
vec![ChatMessage::user(prompt)]
}
};
tracing::debug!(
target: "flows",
role,
message_count = messages.len(),
"[flows] llm.complete: dispatching agent-node completion"
);
let (provider, model) = create_chat_provider(role, &self.config)
.map_err(|e| EngineError::Capability(e.to_string()))?;
let response = provider
.chat(
ChatRequest {
messages: &messages,
tools: None,
stream: None,
max_tokens,
},
&model,
temperature,
)
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
Ok(json!({
"text": response.text,
"tool_calls": response.tool_calls,
"usage": usage_to_json(&response.usage),
"reasoning_content": response.reasoning_content,
}))
}
}
/// Parses a `"composio:<toolkit>:<connection_id>"` `connection_ref` (see the
/// node catalog, `my_docs/ohxtf/commons/12-node-catalog-0.2.md`) and returns
/// the trailing connection id segment. Values that don't match this shape
/// return `None` — the caller logs and falls back to the ambient session
/// account (only Direct mode can actually forward the id today; see
/// [`OpenHumanTools::invoke`]'s doc for the Backend-mode gap this leaves
/// open).
pub(crate) fn composio_connection_id(conn: &str) -> Option<&str> {
let rest = conn.strip_prefix("composio:")?;
let id = rest.rsplit(':').next()?;
(!id.is_empty()).then_some(id)
}
/// Parses a `"http_cred:<name>"` `connection_ref` for [`OpenHumanHttp`]. No
/// host-side HTTP credential store exists yet — this only extracts the name
/// so the adapter can log a clear, actionable warning instead of silently
/// ignoring the reference. See [`OpenHumanHttp::request`]'s doc.
pub(crate) fn http_cred_name(conn: &str) -> Option<&str> {
let name = conn.strip_prefix("http_cred:")?.trim();
(!name.is_empty()).then_some(name)
}
/// Strict, deny-by-default curation check for flow `tool_call` nodes (issue
/// B2 finding #2).
///
/// This is intentionally **stricter** than
/// `memory_sync::composio::providers::is_action_visible_with_pref` — the
/// helper the normal agent tool-call loop uses. That helper is permissive by
/// design for a toolkit it doesn't recognize: it falls back to the
/// `classify_unknown` heuristic and lets the slug through (scope-gated), and
/// treats a prefix-less slug as unconditionally visible. That's safe in the
/// agent loop because the model only ever sees slugs the *backend itself*
/// returned from live tool discovery (`composio_list_tools`) — there is no
/// path for the model to invent a slug that reaches this check. A flow's
/// `tool_call.slug`, by contrast, is a free-form string the flow *author*
/// typed when building the graph; it never round-trips through Composio
/// discovery before `invoke` is called. So here a slug is allowed **only**
/// if it resolves to a real, known toolkit AND is present in that toolkit's
/// curated catalog:
/// - `toolkit_from_slug` fails to extract anything (empty/blank slug) → reject.
/// - the extracted toolkit has no registered provider curated list AND no
/// static `catalog_for_toolkit` entry (i.e. it isn't one of OpenHuman's
/// known/curated toolkits at all — including a made-up prefix like
/// `madeupkit`, or a prefix-less slug like `noop` which `toolkit_from_slug`
/// degrades to treating as its own single-segment "toolkit") → reject.
/// - the toolkit has a catalog but `slug` isn't one of its entries → reject.
/// - otherwise, apply the same per-user read/write/admin scope preference
/// the agent loop uses (`UserScopePref::allows`).
///
-/// // TODO(0.3): this hard-rejects any *real* Composio toolkit that simply
-/// // isn't in the static `catalog_for_toolkit` map yet (there is no
-/// // host-side, offline way to ask "is this actually a valid Composio
-/// // toolkit/action" beyond the curated catalogs OpenHuman ships). That's
-/// // an accepted trade-off for a genuine allowlist rather than a residual
-/// // gap to silently work around — extending `catalog_for_toolkit` (or, if
-/// // a live catalog lookup becomes available, consulting it here) is how a
-/// // newly-supported toolkit gets flow tool-call support.
-async fn is_curated_flow_tool(slug: &str) -> bool {
+/// // (0.3) The former hard-reject of any *real* Composio toolkit not in the
+/// // static `catalog_for_toolkit` map is now lifted for toolkits the user has
+/// // actually connected: when a slug's toolkit has no static curated catalog,
+/// // the gate consults the user's **live connected-toolkit set** (from the
+/// // composio domain) and allows the call iff the user holds an ACTIVE
+/// // connection for that toolkit. A genuinely-unknown/made-up toolkit is never
+/// // connected, so it still rejects. Toolkits OpenHuman *does* ship a static
+/// // catalog for keep their stricter curated-action + per-user scope gating
+/// // unchanged (a connected-but-uncurated action on a cataloged toolkit is
+/// // still rejected — the catalog is the tighter allowlist there).
+///
+/// Returns whether `slug` may be invoked as a flow `tool_call`, given (only when
+/// needed) the user's live connected-toolkit slug set.
+///
+/// Split out from [`is_curated_flow_tool`] as a pure function so the two decision
+/// paths are unit-testable without a live Composio backend: `connected_toolkits`
+/// is `None` when the toolkit has a static catalog (the connected set is never
+/// consulted then) or when the connected set could not be fetched (fail-closed).
+async fn flow_tool_allowed(slug: &str, connected_toolkits: Option<&[String]>) -> bool {
use crate::openhuman::memory_sync::composio::providers::{
catalog_for_toolkit, find_curated, get_provider, load_user_scope_or_default,
toolkit_from_slug,
};
let Some(toolkit) = toolkit_from_slug(slug) else {
+ tracing::debug!(target: "flows", %slug, "[flows] tool_call curation: reject — slug has no extractable toolkit prefix");
return false;
};
- let catalog = get_provider(&toolkit)
+
+ // Path A: a toolkit OpenHuman ships a static curated catalog for keeps its
+ // strict curated-action + per-user scope gating (unchanged from B2).
+ if let Some(catalog) = get_provider(&toolkit)
.and_then(|p| p.curated_tools())
- .or_else(|| catalog_for_toolkit(&toolkit));
- let Some(catalog) = catalog else {
- return false;
+ .or_else(|| catalog_for_toolkit(&toolkit))
+ {
+ let Some(curated) = find_curated(catalog, slug) else {
+ tracing::debug!(target: "flows", %slug, %toolkit, "[flows] tool_call curation: reject — slug is not a curated action of this toolkit");
+ return false;
+ };
+ let pref = load_user_scope_or_default(&toolkit).await;
+ let allowed = pref.allows(curated.scope);
+ tracing::debug!(target: "flows", %slug, %toolkit, allowed, "[flows] tool_call curation: static curated catalog decision");
+ return allowed;
+ }
+
+ // Path B (0.3): no static catalog — allow iff the user has a live ACTIVE
+ // Composio connection for this toolkit. Made-up toolkits are never connected.
+ match connected_toolkits {
+ Some(toolkits) => {
+ let connected = toolkits.iter().any(|t| t.eq_ignore_ascii_case(&toolkit));
+ tracing::debug!(target: "flows", %slug, %toolkit, connected, "[flows] tool_call curation: live connected-toolkit allowlist decision");
+ connected
+ }
+ None => {
+ tracing::warn!(target: "flows", %slug, %toolkit, "[flows] tool_call curation: reject — no static catalog and the connected-toolkit set was unavailable (fail-closed)");
+ false
+ }
+ }
+}
+
+/// Whether `slug`'s toolkit lacks a static curated catalog, i.e. the curation
+/// decision must consult the user's live connected-toolkit set. Kept cheap and
+/// offline (a static `match`) so the common cataloged-toolkit path never pays
+/// for a connected-set fetch.
+fn slug_needs_connected_set(slug: &str) -> bool {
+ use crate::openhuman::memory_sync::composio::providers::{
+ catalog_for_toolkit, get_provider, toolkit_from_slug,
};
- let Some(curated) = find_curated(catalog, slug) else {
- return false;
+ match toolkit_from_slug(slug) {
+ Some(toolkit) => get_provider(&toolkit)
+ .and_then(|p| p.curated_tools())
+ .or_else(|| catalog_for_toolkit(&toolkit))
+ .is_none(),
+ None => false,
+ }
+}
+
+/// The user's live set of ACTIVE-connected Composio toolkit slugs (lowercased),
+/// or `None` when the backend is unreachable and no cached snapshot exists.
+///
+/// Uses [`fetch_connected_integrations_status`] so a transient backend failure
+/// (`Unavailable`) is distinguished from "confirmed zero connections" — on
+/// `Unavailable` we fall back to the last-known (even expired) cache rather than
+/// collapse the allowlist to empty, and only return `None` when there is truly
+/// nothing to go on (the caller then fails closed).
+async fn connected_toolkit_slugs(config: &Config) -> Option<Vec<String>> {
+ use crate::openhuman::composio::{
+ cached_active_integrations_including_expired, fetch_connected_integrations_status,
+ FetchConnectedIntegrationsStatus,
+ };
+
+ let integrations = match fetch_connected_integrations_status(config).await {
+ FetchConnectedIntegrationsStatus::Authoritative(v) => v,
+ FetchConnectedIntegrationsStatus::Unavailable => {
+ match cached_active_integrations_including_expired(config) {
+ Some(v) => {
+ tracing::warn!(target: "flows", "[flows] connected-toolkit lookup: backend unavailable — using last-known (possibly stale) cached connections for the tool_call allowlist");
+ v
+ }
+ None => {
+ tracing::warn!(target: "flows", "[flows] connected-toolkit lookup: backend unavailable and no cached snapshot — connected-toolkit allowlist is empty this call");
+ return None;
+ }
+ }
+ }
};
- let pref = load_user_scope_or_default(&toolkit).await;
- pref.allows(curated.scope)
+
+ Some(
+ integrations
+ .into_iter()
+ .filter(|i| i.connected)
+ .map(|i| i.toolkit.to_ascii_lowercase())
+ .collect(),
+ )
+}
+
+/// Deny-by-default curation gate for a flow `tool_call` slug (see
+/// [`flow_tool_allowed`] for the decision matrix). Fetches the user's live
+/// connected-toolkit set only when the slug's toolkit has no static catalog.
+async fn is_curated_flow_tool(config: &Config, slug: &str) -> bool {
+ let connected = if slug_needs_connected_set(slug) {
+ connected_toolkit_slugs(config).await
+ } else {
+ None
+ };
+ flow_tool_allowed(slug, connected.as_deref()).await
+}
+
+/// Finds the connected account a Composio `connection_id` refers to within a
+/// live connected-integrations snapshot, returning `(toolkit, display_label)`.
+/// UI-safe: the label is the pre-derived [`IntegrationConnection::label`], never
+/// a raw account-identity field. Pure over the snapshot so it is unit-testable.
+fn resolve_account<'a>(
+ integrations: &'a [crate::openhuman::composio::ConnectedIntegration],
+ connection_id: &str,
+) -> Option<(&'a str, Option<&'a str>)> {
+ integrations.iter().find_map(|integ| {
+ integ
+ .connections
+ .iter()
+ .find(|c| c.connection_id == connection_id)
+ .map(|c| (integ.toolkit.as_str(), c.label.as_deref()))
+ })
+}
+
+/// Resolves a Composio `connection_id` to the specific connected account it
+/// targets, for logging "which account was used". Best-effort: `None` when the
+/// id isn't found in the user's live connected accounts (stale cache / foreign
+/// id) or the backend is unreachable.
+async fn resolve_composio_account(
+ config: &Config,
+ connection_id: &str,
+) -> Option<(String, Option<String>)> {
+ let integrations = crate::openhuman::composio::fetch_connected_integrations(config).await;
+ resolve_account(&integrations, connection_id)
+ .map(|(toolkit, label)| (toolkit.to_string(), label.map(str::to_string)))
}
/// [`ToolInvoker`] adapter over Composio (`src/openhuman/composio/client.rs`).
///
/// **B2 (closes two B1 deviations, see
/// `my_docs/ohxtf/b2-triggers-trust/01-triggers-and-trust.md` §4-5):**
/// - **Curation + scope (hard allowlist)**: every call is checked against
/// [`is_curated_flow_tool`] — a deny-by-default gate that only allows a
/// slug resolving to a *known, curated* toolkit action, unlike the general
/// agent tool-call path's more permissive
/// `memory_sync::composio::providers::is_action_visible_with_pref` (see
/// [`is_curated_flow_tool`]'s doc for why the two differ). A non-curated /
/// unrecognized / out-of-scope slug is rejected with
/// `EngineError::Capability("tool not permitted: <slug>")` before any
/// Composio call. **As of tinyflows 0.3 this is load-bearing, not merely
/// defense-in-depth**: integration-node config (including `slug`) is now
/// `=`-expression evaluated against upstream/trigger data before `invoke`,
/// so a trigger payload *can* influence which tool a `=`-derived slug
/// resolves to. The curation gate runs on the **resolved** slug (verified:
/// a `=item.tool`-derived unknown slug is rejected here before Composio),
/// constraining any data-derived tool to the user's curated, in-scope,
/// connected set — and it still closes the case where an author hand-types
/// an arbitrary/typo'd slug.
/// - **connection_ref**: `conn` (`"composio:<toolkit>:<connection_id>"`) is
/// now parsed and forwarded to `direct_execute` (Composio Direct mode).
/// Backend mode's `execute_tool` still has no per-call account-scoping
/// path — that's a backend API gap, not something this seam can close
/// alone — so a `connection_ref` under Backend mode logs a warning and
/// falls back to the ambient signed-in account (documented stub; see
/// `composio_connection_id`).
/// - **Trust gate**: invocation is also routed through the OpenHuman
/// `ApprovalGate` (mirrors `tinyagents/middleware.rs::ApprovalSecurityMiddleware`)
/// before dispatch, closing the Codex P1 finding that flow tool nodes
/// bypassed the Network/tool approval gate entirely. `ops::flows_run` /
/// `flows_resume` scope a `TrustedAutomation { Workflow }` origin around
/// the whole run, so the gate either auto-allows (pre-declared trust root)
/// or — when the flow's `require_approval` is set — parks for a real
/// decision. No gate installed (unit tests, some hosts) means no gating,
/// same as the existing agent tool-loop middleware.
///
/// // SECURITY NOTE (tinyflows 0.3, now the pinned version): integration nodes
/// // `=`-resolve config from upstream/trigger data, so a trigger-driven flow
/// // whose `slug`/`url` is `=`-derived lets untrusted trigger data pick *which*
/// // curated + in-scope + connected tool/endpoint runs (blast radius bounded by
/// // the curation + scope + connection checks above and the approval gate).
/// // For such flows authors should set `require_approval`. FOLLOW-UP: auto-force
/// // approval when a trigger-driven run's tool/http config contains `=`-exprs.
pub struct OpenHumanTools {
pub config: Arc<Config>,
}
#[async_trait]
impl ToolInvoker for OpenHumanTools {
async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result<Value> {
// Curation + scope gate — hard allowlist (see [`is_curated_flow_tool`]'s
// doc for why this differs from the general agent tool-call path).
// Runs before anything else — a rejected slug never reaches the
// composio client at all.
- if !is_curated_flow_tool(slug).await {
+ if !is_curated_flow_tool(&self.config, slug).await {
tracing::warn!(
target: "flows",
%slug,
"[flows] tool_call: rejected — not a recognized curated toolkit action, or out \
of the user's configured scope"
);
return Err(EngineError::Capability(format!(
"tool not permitted: {slug}"
)));
}
// Approval gate (see the struct doc). Mirrors
// `tinyagents/middleware.rs::ApprovalSecurityMiddleware::wrap_tool`'s
// shape exactly: compute summary/redacted args only when a gate is
// installed, deny short-circuits before any composio call, allow
// records an audit id to close out after the call resolves.
let mut audit_id: Option<String> = None;
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
let summary = crate::openhuman::approval::summarize_action(slug, &args);
let redacted = crate::openhuman::approval::redact_args(&args);
let (outcome, request_id) = gate.intercept_audited(slug, &summary, redacted).await;
match outcome {
crate::openhuman::approval::GateOutcome::Deny { reason } => {
return Err(EngineError::Capability(reason));
}
crate::openhuman::approval::GateOutcome::Allow => audit_id = request_id,
}
}
let kind = create_composio_client(&self.config)
.map_err(|e| EngineError::Capability(e.to_string()))?;
let args_opt = if args.is_null() { None } else { Some(args) };
let connection_id = conn.and_then(composio_connection_id);
+ // Resolve the connection_ref to the SPECIFIC connected account it names,
+ // so we can log which account executes and validate it against the
+ // user's live connected set. Ambient-session fallback is used ONLY when
+ // no connection_ref was supplied.
+ let resolved_account = match connection_id {
+ Some(id) => Some((id, resolve_composio_account(&self.config, id).await)),
+ None => None,
+ };
+
tracing::debug!(
target: "flows",
%slug,
mode = kind.mode(),
has_connection_ref = connection_id.is_some(),
"[flows] tool_call: invoking composio tool"
);
let response = match kind {
ComposioClientKind::Backend(client) => {
- if connection_id.is_some() {
- tracing::warn!(
- target: "flows",
- %slug,
- "[flows] tool_call: connection_ref set but backend mode has no per-call \
- account-scoping path yet — using the ambient session account \
- (documented stub, see caps.rs's OpenHumanTools doc)"
- );
+ if let Some((id, resolved)) = &resolved_account {
+ match resolved {
+ Some((toolkit, label)) => tracing::warn!(
+ target: "flows",
+ %slug,
+ connection_id = %id,
+ %toolkit,
+ account = label.as_deref().unwrap_or("<unlabeled>"),
+ "[flows] tool_call: connection_ref resolves to a specific account, but \
+ backend mode has no per-call account-scoping path yet — using the \
+ ambient session account instead (documented stub, see caps.rs's \
+ OpenHumanTools doc)"
+ ),
+ None => tracing::warn!(
+ target: "flows",
+ %slug,
+ connection_id = %id,
+ "[flows] tool_call: connection_ref set but backend mode has no per-call \
+ account-scoping path yet — using the ambient session account \
+ (documented stub, see caps.rs's OpenHumanTools doc)"
+ ),
+ }
}
client
.execute_tool(slug, args_opt)
.await
.map_err(|e| EngineError::Capability(e.to_string()))
}
- ComposioClientKind::Direct(tool) => direct_execute(
- &tool,
- slug,
- args_opt,
- &self.config.composio.entity_id,
- connection_id,
- )
- .await
- .map_err(|e| EngineError::Capability(e.to_string())),
+ ComposioClientKind::Direct(tool) => {
+ match &resolved_account {
+ Some((id, Some((toolkit, label)))) => tracing::info!(
+ target: "flows",
+ %slug,
+ connection_id = %id,
+ %toolkit,
+ account = label.as_deref().unwrap_or("<unlabeled>"),
+ "[flows] tool_call: executing against the resolved connected account"
+ ),
+ Some((id, None)) => tracing::warn!(
+ target: "flows",
+ %slug,
+ connection_id = %id,
+ "[flows] tool_call: connection_ref connection_id not found among the user's \
+ live connected accounts (stale cache or foreign id) — forwarding to \
+ Composio Direct mode as-is"
+ ),
+ None => tracing::debug!(
+ target: "flows",
+ %slug,
+ "[flows] tool_call: no connection_ref — using the ambient signed-in account"
+ ),
+ }
+ direct_execute(
+ &tool,
+ slug,
+ args_opt,
+ &self.config.composio.entity_id,
+ connection_id,
+ )
+ .await
+ .map_err(|e| EngineError::Capability(e.to_string()))
+ }
};
if let Some(id) = audit_id {
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
let exec = if response.is_ok() {
crate::openhuman::approval::ExecutionOutcome::Success
} else {
crate::openhuman::approval::ExecutionOutcome::Failure
};
gate.record_execution(
&id,
exec,
response.as_ref().err().map(ToString::to_string).as_deref(),
);
}
}
serde_json::to_value(response?).map_err(|e| EngineError::Capability(e.to_string()))
}
}
/// [`HttpClient`] adapter over `HttpRequestTool`
/// (`src/openhuman/tools/impl/network/http_request.rs`). Allowlist + DNS-rebind
/// guard live inside `execute`, so this adapter gets them for free.
///
/// **B2:** also routes through the OpenHuman `ApprovalGate` before dispatch
/// (same rationale/shape as [`OpenHumanTools::invoke`] — closes the Codex P1
-/// finding that flow HTTP nodes bypassed the Network approval gate). A
-/// `"http_cred:<name>"` `connection_ref` is parsed but there is no HTTP
-/// credential store to resolve it against yet (documented stub, see
-/// `http_cred_name`) — the request proceeds without injecting stored
-/// credentials.
+/// finding that flow HTTP nodes bypassed the Network approval gate).
+///
+/// **Phase 2 — `http_cred:<name>` resolution:** a `"http_cred:<name>"`
+/// `connection_ref` is now resolved against the credentials domain's
+/// [`HttpCredentialsStore`] (encrypted-at-rest bearer/basic/header templates).
+/// The resolved auth header is injected **server-side** into the outbound
+/// request — after the approval gate has already computed its redacted audit
+/// summary — so the secret is never surfaced to the approval UI, the flow
+/// engine/graph, the node's output, or the logs (only the header *name* and
+/// scheme are logged; the value is redacted). A `connection_ref` that names an
+/// **unknown** credential fails the request closed (`EngineError::Capability`)
+/// rather than silently sending it unauthenticated.
pub struct OpenHumanHttp {
pub security: Arc<SecurityPolicy>,
pub http_config: HttpRequestConfig,
+ pub http_creds: Arc<HttpCredentialsStore>,
}
-#[async_trait]
-impl HttpClient for OpenHumanHttp {
- async fn request(&self, request: Value, conn: Option<&str>) -> Result<Value> {
- const TOOL_NAME: &str = "flows_http_request";
+/// Resolves an optional HTTP `connection_ref` to the stored credential to
+/// inject. Split out as a free function (over the store, not `&self`) so the
+/// resolve/fail-closed policy is unit-testable without constructing a full
+/// [`OpenHumanHttp`] adapter.
+///
+/// - `None` conn, or a `connection_ref` whose prefix isn't `http_cred:` →
+/// `Ok(None)` (no credential to inject; a non-`http_cred:` prefix is logged
+/// and ignored, matching the pre-Phase-2 behavior).
+/// - a `http_cred:<name>` naming a **known** credential → `Ok(Some(cred))`
+/// (secret-bearing — the caller injects it server-side, never logs it).
+/// - a `http_cred:<name>` naming an **unknown** credential, a malformed
+/// (empty/whitespace-only) name, or a store error → `Err` — the request
+/// must fail closed, never proceed unauthenticated. Distinguishing "no
+/// `http_cred:` prefix at all" from "`http_cred:` prefix with a malformed
+/// name" matters: [`http_cred_name`] collapses both to `None`, which would
+/// otherwise let a typo'd or data-derived empty ref (e.g. `"http_cred:"`)
+/// silently fall through to an unauthenticated request (Codex P2 finding).
+fn resolve_http_credential(
+ store: &HttpCredentialsStore,
+ conn: Option<&str>,
+) -> Result<Option<HttpCredential>> {
+ let Some(conn) = conn else {
+ return Ok(None);
+ };
+ if conn.strip_prefix("http_cred:").is_none() {
+ tracing::debug!(target: "flows", %conn, "[flows] http conn: unrecognized connection_ref prefix (expected `http_cred:<name>`) — ignoring");
+ return Ok(None);
+ }
+ let Some(name) = http_cred_name(conn) else {
+ tracing::warn!(
+ target: "flows",
+ %conn,
+ "[flows] http_request: connection_ref has the `http_cred:` prefix but no credential \
+ name — failing the request closed rather than sending it unauthenticated"
+ );
+ return Err(EngineError::Capability(format!(
+ "http_request connection_ref has a malformed http_cred name: {conn:?}"
+ )));
+ };
- let mut audit_id: Option<String> = None;
- if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
- let summary = crate::openhuman::approval::summarize_action(TOOL_NAME, &request);
- let redacted = crate::openhuman::approval::redact_args(&request);
- let (outcome, request_id) = gate.intercept_audited(TOOL_NAME, &summary, redacted).await;
- match outcome {
- crate::openhuman::approval::GateOutcome::Deny { reason } => {
- return Err(EngineError::Capability(reason));
- }
- crate::openhuman::approval::GateOutcome::Allow => audit_id = request_id,
- }
+ match store.get(name) {
+ Ok(Some(cred)) => {
+ tracing::debug!(
+ target: "flows",
+ cred = %name,
+ scheme = cred.scheme.as_str(),
+ "[flows] http_request: resolved http_cred (secret redacted)"
+ );
+ Ok(Some(cred))
}
-
- if let Some(name) = conn.and_then(http_cred_name) {
+ Ok(None) => {
tracing::warn!(
target: "flows",
cred = %name,
- "[flows] http_request: connection_ref names an http_cred secret, but no HTTP \
- credential store exists yet — proceeding WITHOUT injecting stored credentials \
- (documented stub, see caps.rs's OpenHumanHttp doc)"
+ "[flows] http_request: connection_ref names an unknown http_cred — failing the \
+ request closed rather than sending it unauthenticated"
);
- } else if let Some(c) = conn {
- tracing::debug!(target: "flows", conn = %c, "[flows] http conn: unrecognized connection_ref prefix (expected `http_cred:<name>`) — ignoring");
+ Err(EngineError::Capability(format!(
+ "http_request connection_ref names an unknown http_cred: {name}"
+ )))
+ }
+ Err(e) => {
+ tracing::error!(
+ target: "flows",
+ cred = %name,
+ error = %e,
+ "[flows] http_request: failed to resolve http_cred from the store"
+ );
+ Err(EngineError::Capability(format!(
+ "failed to resolve http_cred '{name}': {e}"
+ )))
+ }
+ }
+}
+
+/// Merges a resolved credential's auth header into the outbound `request`'s
+/// `headers` object (creating it when absent), returning the header **name**
+/// that was injected for redacted logging. The header value carries the secret
+/// and is placed only into the request handed to `HttpRequestTool` — it is
+/// never logged or returned. An explicit stored credential wins over any inline
+/// same-named header the flow author set.
+fn inject_http_credential(request: &mut Value, cred: &HttpCredential) -> Result<String> {
+ let (header_name, header_value) = cred
+ .to_header()
+ .map_err(|e| EngineError::Capability(e.to_string()))?;
+
+ let obj = request.as_object_mut().ok_or_else(|| {
+ EngineError::Capability("http_request config must be a JSON object".to_string())
+ })?;
+ let headers_entry = obj
+ .entry("headers")
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
+ // A flow author may leave `headers` unset (null) — coerce to an object so
+ // the credential still injects. A non-object, non-null `headers` is a
+ // malformed config we refuse rather than silently drop the credential.
+ if headers_entry.is_null() {
+ *headers_entry = Value::Object(serde_json::Map::new());
+ }
+ let headers_obj = headers_entry.as_object_mut().ok_or_else(|| {
+ EngineError::Capability("http_request `headers` must be a JSON object".to_string())
+ })?;
+ headers_obj.insert(header_name.clone(), Value::String(header_value));
+
+ tracing::info!(
+ target: "flows",
+ cred = %cred.name,
+ scheme = cred.scheme.as_str(),
+ header = %header_name,
+ "[flows] http_request: injected stored credential header (value redacted)"
+ );
+ Ok(header_name)
+}
+
+#[async_trait]
+impl HttpClient for OpenHumanHttp {
+ async fn request(&self, mut request: Value, conn: Option<&str>) -> Result<Value> {
+ const TOOL_NAME: &str = "flows_http_request";
+
+ // Autonomy-tier gate (Phase 2): an http_request node reaches the network,
+ // so it is Network-class. A read-only run `Block`s here and never
+ // dispatches; Supervised/Full fall through to the ApprovalGate below.
+ // `gate_call_for_tier` is what actually performs the `Prompt` round-trip
+ // — it escalates a Supervised `Prompt` decision into a forced approval
+ // regardless of the flow's own `require_approval` toggle (Codex P1).
+ let tier_decision =
+ enforce_node_tier_gate(&self.security, CommandClass::Network, "http_request")?;
+
+ // The approval gate summarizes/redacts the request BEFORE any credential
+ // is injected, so a stored secret never lands in the approval UI or
+ // audit trail. Injection happens strictly after this point.
+ let summary = crate::openhuman::approval::summarize_action(TOOL_NAME, &request);
+ let redacted = crate::openhuman::approval::redact_args(&request);
+ let (outcome, audit_id) =
+ gate_call_for_tier(tier_decision, TOOL_NAME, &summary, redacted).await;
+ if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome {
+ return Err(EngineError::Capability(reason));
+ }
+
+ // Resolve `http_cred:<name>` to a stored credential and inject its auth
+ // header server-side. An unknown name fails the request closed (see
+ // `resolve_http_credential`) — we never send it unauthenticated.
+ if let Some(cred) = resolve_http_credential(&self.http_creds, conn)? {
+ inject_http_credential(&mut request, &cred)?;
}
let tool = HttpRequestTool::new(
self.security.clone(),
self.http_config.allowed_domains.clone(),
self.http_config.max_response_size,
self.http_config.timeout_secs,
);
tracing::debug!(
target: "flows",
method = ?request.get("method"),
url = ?request.get("url"),
"[flows] http_request: dispatching outbound request"
);
// `request` is already `{ method, url, headers?, body? }` — the node's
// config is the request descriptor; `HttpRequestTool::execute` reads
// only those keys and ignores the rest (e.g. `connection_ref`,
// `on_error`), so passing the whole config through is safe.
let result = tool.execute(request).await;
let outcome: Result<Value> = match result {
Ok(result) if result.is_error => {
// `HttpRequestTool::execute` always returns `Ok`, using
// `is_error` to signal a failed request (non-2xx, DNS/allowlist
// rejection, timeout, …) — surface that as a capability error
// so the engine's `on_error`/`retry` policy can act on it.
Err(EngineError::Capability(result.text()))
}
Ok(result) => Ok(json!({ "text": result.text() })),
Err(e) => Err(EngineError::Capability(e.to_string())),
};
if let Some(id) = audit_id {
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
let exec = if outcome.is_ok() {
crate::openhuman::approval::ExecutionOutcome::Success
} else {
crate::openhuman::approval::ExecutionOutcome::Failure
};
gate.record_execution(
&id,
exec,
outcome.as_ref().err().map(ToString::to_string).as_deref(),
);
}
}
outcome
}
}
/// [`CodeRunner`] adapter running sandboxed user code via
/// `src/openhuman/sandbox/ops.rs` (`resolve_sandbox_policy` +
/// `execute_in_sandbox`), modeled on
/// `src/openhuman/tools/impl/system/node_exec.rs::run_sandboxed`.
///
/// **Mismatch handled here:** the sandbox runs a shell command string, not a
/// `(language, source, input)` triple. `source` is treated as a function body
/// receiving the serialized `input` items array and returning the node's
/// output — this convention is a B1 design choice (not specified by the
/// crate), matching the mock's "function body" tests
/// (`tinyflows::nodes::integration::code` — e.g. `"source": "return 1;"`).
///
/// Requires `node`/`python3` on the `PATH` the sandbox backend runs under;
/// there is no managed toolchain wiring here (unlike `node_exec`'s
/// `NodeBootstrap`).
+///
+/// **Phase 2 — autonomy-tier gating:** a `code` node runs arbitrary user code
+/// in a sandbox, so it is treated as [`CommandClass::Write`] (state-changing but
+/// sandbox-bounded — not inherently catastrophic). Before dispatch it consults
+/// [`enforce_node_tier_gate`]: a read-only run `Block`s and never executes; a
+/// Supervised run then routes through the `ApprovalGate` (Write ⇒ `Prompt`); a
+/// Full run executes silently. This closes the prior gap where the code node had
+/// no policy check and no approval gate at all.
pub struct OpenHumanCode {
pub config: Arc<Config>,
+ pub security: Arc<SecurityPolicy>,
}
const CODE_RUN_TIMEOUT_SECS: u64 = 60;
#[async_trait]
impl CodeRunner for OpenHumanCode {
async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result<Value> {
+ // Autonomy-tier gate (Phase 2): sandboxed arbitrary-code execution is
+ // Write-class. A read-only run `Block`s here and never spawns anything;
+ // Supervised/Full fall through to the ApprovalGate below.
+ let tier_decision = enforce_node_tier_gate(&self.security, CommandClass::Write, "code")?;
+
+ // Approval gate (mirrors OpenHumanTools/OpenHumanHttp): `gate_call_for_tier`
+ // is what turns a Supervised-tier `Prompt` decision into a real human
+ // round-trip before any code runs — escalating past the flow's own
+ // `require_approval` toggle when the tier itself says "ask me" (Codex P1).
+ // A Deny short-circuits. The audit summary is computed on a redacted view
+ // of the request, never the raw source secrets, matching the other
+ // acting adapters.
+ let action = json!({ "language": format!("{language:?}"), "source": source });
+ let summary = crate::openhuman::approval::summarize_action("flows_code", &action);
+ let redacted = crate::openhuman::approval::redact_args(&action);
+ let (gate_outcome, audit_id) =
+ gate_call_for_tier(tier_decision, "flows_code", &summary, redacted).await;
+ if let crate::openhuman::approval::GateOutcome::Deny { reason } = gate_outcome {
+ return Err(EngineError::Capability(reason));
+ }
+
+ let outcome: Result<Value> = async {
let policy = resolve_sandbox_policy(
SandboxMode::Sandboxed,
&self.config.action_dir,
&self.config.runtime,
false,
);
// Work dir lives under `action_dir` (the sandbox workspace root). We keep
// its path *relative* to `action_dir` so the run command works on every
// backend: for Local, `execute_in_sandbox`'s `working_dir` is the host
// cwd; for Docker, `action_dir` is bind-mounted at `/workspace` with
// `-w /workspace`. Host-absolute paths would not exist inside the
// container, so we pass `action_dir` as the working dir and reference the
// script/input by their `action_dir`-relative paths.
let rel_dir = std::path::Path::new(".flows_code").join(uuid::Uuid::new_v4().to_string());
let work_dir = self.config.action_dir.join(&rel_dir);
tokio::fs::create_dir_all(&work_dir)
.await
.map_err(|e| EngineError::Capability(format!("failed to create code work dir: {e}")))?;
let (script_name, interpreter, script_body) = match language {
CodeLanguage::JavaScript => ("script.js", "node", js_harness(source)),
CodeLanguage::Python => ("script.py", "python3", python_harness(source)),
};
let script_path = work_dir.join(script_name);
let input_path = work_dir.join("input.json");
let input_json = serde_json::to_string(&input)
.map_err(|e| EngineError::Capability(format!("failed to serialize code input: {e}")))?;
tokio::fs::write(&script_path, script_body)
.await
.map_err(|e| EngineError::Capability(format!("failed to write code script: {e}")))?;
tokio::fs::write(&input_path, input_json)
.await
.map_err(|e| EngineError::Capability(format!("failed to write code input: {e}")))?;
// Backend-agnostic, `action_dir`-relative command paths (see above).
let rel_script = rel_dir.join(script_name);
let rel_input = rel_dir.join("input.json");
let command = format!(
"{} {} {}",
shell_quote(interpreter),
shell_quote(&rel_script.to_string_lossy()),
shell_quote(&rel_input.to_string_lossy()),
);
let mut extra_env = std::collections::HashMap::new();
if let Ok(host_path) = std::env::var("PATH") {
extra_env.insert("PATH".to_string(), host_path);
}
tracing::debug!(
target: "flows",
?language,
work_dir = %work_dir.display(),
"[flows] code: running sandboxed script"
);
let exec_result = execute_in_sandbox(
&policy,
&command,
&self.config.action_dir,
extra_env,
std::time::Duration::from_secs(CODE_RUN_TIMEOUT_SECS),
)
.await;
// Always clean up the work dir — even when `execute_in_sandbox` itself
// errors (e.g. a spawn failure) — so temp scripts never leak.
if let Err(e) = tokio::fs::remove_dir_all(&work_dir).await {
tracing::debug!(target: "flows", error = %e, "[flows] code: failed to clean up work dir (non-fatal)");
}
let result = exec_result
.map_err(|e| EngineError::Capability(format!("sandbox execution failed: {e}")))?;
if !result.success() {
return Err(EngineError::Capability(format!(
"code node exited non-zero (timed_out={}): {}",
result.timed_out, result.stderr
)));
}
serde_json::from_str(result.stdout.trim())
.map_err(|e| EngineError::Capability(format!("code output was not valid JSON: {e}")))
+ }
+ .await;
+
+ // Close out the approval audit with the run's success/failure (mirrors
+ // OpenHumanTools/OpenHumanHttp).
+ if let Some(id) = audit_id {
+ if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
+ let exec = if outcome.is_ok() {
+ crate::openhuman::approval::ExecutionOutcome::Success
+ } else {
+ crate::openhuman::approval::ExecutionOutcome::Failure
+ };
+ gate.record_execution(
+ &id,
+ exec,
+ outcome.as_ref().err().map(ToString::to_string).as_deref(),
+ );
+ }
+ }
+
+ outcome
}
}
/// Wraps user `source` as a function body receiving `input`, executed by Node,
/// printing the JSON result (or `null`) to stdout.
fn js_harness(source: &str) -> String {
format!(
"const fs = require('fs');\n\
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));\n\
const __result__ = (function(input) {{\n{source}\n}})(input);\n\
process.stdout.write(JSON.stringify(__result__ === undefined ? null : __result__));\n"
)
}
/// Wraps user `source` as a function body receiving `input`, executed by
/// Python, printing the JSON result (or `null`) to stdout.
fn python_harness(source: &str) -> String {
let indented: String = if source.trim().is_empty() {
" pass".to_string()
} else {
source
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
};
format!(
"import sys, json\n\
with open(sys.argv[1]) as __f__:\n input = json.load(__f__)\n\
def __user_fn__(input):\n{indented}\n return None\n\
__result__ = __user_fn__(input)\n\
print(json.dumps(__result__))\n"
)
}
/// POSIX single-quote shell escaping, mirroring
/// `tools/impl/system/node_exec.rs::shell_quote`.
fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
/// [`StateStore`] adapter over the `flows::` domain's `flow_state` KV table.
pub struct FlowStateStore {
pub config: Arc<Config>,
pub namespace: String,
}
#[async_trait]
impl StateStore for FlowStateStore {
async fn load(&self, key: &str) -> Result<Option<Value>> {
flows::kv_get(&self.config, &self.namespace, key)
.map_err(|e| EngineError::Capability(e.to_string()))
}
async fn store(&self, key: &str, value: Value) -> Result<()> {
flows::kv_set(&self.config, &self.namespace, key, &value)
.map_err(|e| EngineError::Capability(e.to_string()))
}
}
/// Builds the [`Capabilities`] bundle for one run, wiring each of the five
/// host-injected traits to a real OpenHuman adapter (see each adapter above for
/// its contract).
///
/// `state_namespace` scopes the [`FlowStateStore`] KV so two saved flows that
/// use the same state key never read or overwrite each other — callers pass a
/// per-flow namespace (e.g. `"flow:<id>"`).
pub fn build_capabilities(config: Arc<Config>, state_namespace: impl Into<String>) -> Capabilities {
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
&config.action_dir,
));
let http_config = config.http_request.clone();
+ let http_creds = Arc::new(HttpCredentialsStore::from_config(&config));
Capabilities {
llm: Arc::new(OpenHumanLlm {
config: config.clone(),
}),
tools: Arc::new(OpenHumanTools {
config: config.clone(),
}),
http: Arc::new(OpenHumanHttp {
- security,
+ security: security.clone(),
http_config,
+ http_creds,
}),
code: Arc::new(OpenHumanCode {
config: config.clone(),
+ security,
}),
state: Arc::new(FlowStateStore {
config,
namespace: state_namespace.into(),
}),
}
}
/// Opens the durable, cross-process checkpointer a `flows_run` uses via
/// `tinyflows::engine::run_with_checkpointer` — the crate's own
/// `tinyagents::graph::SqliteCheckpointer`, stored under
/// `<workspace_dir>/flows/checkpoints.db`.
///
/// Deliberately **not** a bespoke checkpointer: the crate ships its own
/// SQLite-backed `Checkpointer<State>` impl (feature `sqlite`, already enabled
/// on the `tinyagents` dependency), so the seam just opens it — mirrors the
/// construction in `src/openhuman/agent_orchestration/delegation.rs`.
pub fn open_flow_checkpointer(
config: &Config,
) -> anyhow::Result<Arc<dyn tinyflows::engine::Checkpointer<serde_json::Value>>> {
let db_path = config.workspace_dir.join("flows").join("checkpoints.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create flows directory: {}", parent.display()))?;
}
tracing::debug!(target: "flows", db = %db_path.display(), "[flows] opening checkpointer");
Ok(Arc::new(
SqliteCheckpointer::<serde_json::Value>::open(&db_path)
.with_context(|| format!("Failed to open flows checkpointer: {}", db_path.display()))?,
))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::agent::prompts::types::IntegrationConnection;
+ use crate::openhuman::composio::ConnectedIntegration;
+
+ fn integration(
+ toolkit: &str,
+ connected: bool,
+ connections: Vec<IntegrationConnection>,
+ ) -> ConnectedIntegration {
+ ConnectedIntegration {
+ toolkit: toolkit.to_string(),
+ description: String::new(),
+ tools: Vec::new(),
+ gated_tools: Vec::new(),
+ connected,
+ connections,
+ non_active_status: None,
+ }
+ }
+
+ fn connection(id: &str, label: Option<&str>, is_default: bool) -> IntegrationConnection {
+ IntegrationConnection {
+ connection_id: id.to_string(),
+ label: label.map(str::to_string),
+ is_default,
+ }
+ }
+
+ /// A `composio:<toolkit>:<connection_id>` ref parses to its id and that id
+ /// resolves to the SPECIFIC connected account (toolkit + display label) —
+ /// not the toolkit's default connection.
+ #[test]
+ fn connection_ref_resolves_to_the_chosen_account() {
+ let integrations = vec![integration(
+ "gmail",
+ true,
+ vec![
+ connection("conn_work", Some("work@example.com"), true),
+ connection("conn_home", Some("home@example.com"), false),
+ ],
+ )];
+
+ let id = composio_connection_id("composio:gmail:conn_home")
+ .expect("well-formed composio connection_ref should parse");
+ assert_eq!(id, "conn_home");
+
+ let (toolkit, label) =
+ resolve_account(&integrations, id).expect("id should resolve to a connected account");
+ assert_eq!(toolkit, "gmail");
+ // The non-default account was chosen — resolution is by id, not default.
+ assert_eq!(label, Some("home@example.com"));
+
+ // An id the user does not hold resolves to nothing (best-effort log path).
+ assert!(resolve_account(&integrations, "conn_unknown").is_none());
+ }
+
+ /// A made-up toolkit that OpenHuman ships no static catalog for and the user
+ /// has NOT connected still rejects — even when the connected set is present
+ /// but simply doesn't contain it.
+ #[tokio::test]
+ async fn unknown_toolkit_still_rejects() {
+ use crate::openhuman::memory_sync::composio::providers::{
+ catalog_for_toolkit, get_provider,
+ };
+ // Precondition: `flowstestkit` is genuinely uncatalogued, so the decision
+ // flows through the connected-set path (not the static curated path).
+ assert!(catalog_for_toolkit("flowstestkit").is_none());
+ assert!(get_provider("flowstestkit").is_none());
+
+ // No connected set at all → fail-closed reject.
+ assert!(!flow_tool_allowed("FLOWSTESTKIT_DO_THING", None).await);
+ // Connected set present but does not include this toolkit → reject.
+ assert!(!flow_tool_allowed("FLOWSTESTKIT_DO_THING", Some(&["gmail".to_string()])).await);
+ // A blank slug is always rejected.
+ assert!(!flow_tool_allowed("", Some(&["flowstestkit".to_string()])).await);
+ }
+
+ /// A real Composio toolkit OpenHuman ships no static catalog for now PASSES
+ /// once the user has an ACTIVE connection for it (the TODO(0.3) fix) — the
+ /// exact same slug that rejects above.
+ #[tokio::test]
+ async fn connected_uncatalogued_toolkit_now_passes() {
+ use crate::openhuman::memory_sync::composio::providers::{
+ catalog_for_toolkit, get_provider,
+ };
+ assert!(catalog_for_toolkit("flowstestkit").is_none());
+ assert!(get_provider("flowstestkit").is_none());
+
+ assert!(
+ flow_tool_allowed("FLOWSTESTKIT_DO_THING", Some(&["flowstestkit".to_string()])).await
+ );
+ // Case-insensitive match on the toolkit slug.
+ assert!(
+ flow_tool_allowed("FLOWSTESTKIT_DO_THING", Some(&["FlowsTestKit".to_string()])).await
+ );
+ }
+
+ fn http_cred_store() -> (tempfile::TempDir, HttpCredentialsStore) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ // encrypt=true exercises the ChaCha20-Poly1305 at-rest path.
+ let store = HttpCredentialsStore::new(dir.path(), true);
+ (dir, store)
+ }
+
+ /// A `http_cred:<name>` ref resolves to the stored bearer credential and
+ /// injects `Authorization: Bearer <token>` onto the outbound request.
+ #[test]
+ fn http_cred_resolves_and_injects_bearer_header() {
+ let (_dir, store) = http_cred_store();
+ store
+ .upsert(&HttpCredential::bearer("stripe", "sk_live_secret"))
+ .unwrap();
+
+ let cred = resolve_http_credential(&store, Some("http_cred:stripe"))
+ .expect("resolve ok")
+ .expect("credential present");
+
+ let mut request = json!({ "method": "GET", "url": "https://api.example.com" });
+ let header = inject_http_credential(&mut request, &cred).unwrap();
+ assert_eq!(header, "Authorization");
+ assert_eq!(
+ request["headers"]["Authorization"],
+ json!("Bearer sk_live_secret")
+ );
+ }
+
+ /// A custom-header credential injects under its own header name while
+ /// preserving any headers the flow author already set.
+ #[test]
+ fn http_cred_injection_preserves_existing_headers() {
+ let (_dir, store) = http_cred_store();
+ store
+ .upsert(&HttpCredential::header("apikey", "X-API-Key", "topsecret"))
+ .unwrap();
+ let cred = resolve_http_credential(&store, Some("http_cred:apikey"))
+ .unwrap()
+ .unwrap();
+
+ let mut request = json!({
+ "method": "POST",
+ "url": "https://api.example.com",
+ "headers": { "Content-Type": "application/json" }
+ });
+ inject_http_credential(&mut request, &cred).unwrap();
+ assert_eq!(
+ request["headers"]["Content-Type"],
+ json!("application/json")
+ );
+ assert_eq!(request["headers"]["X-API-Key"], json!("topsecret"));
+ }
+
+ /// A basic credential injects `Authorization: Basic ...` even when the flow
+ /// author set no `headers` object at all.
+ #[test]
+ fn http_cred_injects_basic_into_absent_headers() {
+ let (_dir, store) = http_cred_store();
+ store
+ .upsert(&HttpCredential::basic("acme", "alice", "pw"))
+ .unwrap();
+ let cred = resolve_http_credential(&store, Some("http_cred:acme"))
+ .unwrap()
+ .unwrap();
+
+ let mut request = json!({ "method": "GET", "url": "https://x.example.com" });
+ inject_http_credential(&mut request, &cred).unwrap();
+ let value = request["headers"]["Authorization"]
+ .as_str()
+ .expect("Authorization header injected");
+ assert!(
+ value.starts_with("Basic "),
+ "unexpected basic header: {value}"
+ );
+ }
+
+ /// A `http_cred:<name>` naming a credential that does not exist FAILS the
+ /// request closed — it must never proceed silently unauthenticated.
+ #[test]
+ fn unknown_http_cred_fails_closed() {
+ let (_dir, store) = http_cred_store();
+ let result = resolve_http_credential(&store, Some("http_cred:ghost"));
+ assert!(result.is_err(), "unknown http_cred must fail closed");
+ }
+
+ /// A malformed `http_cred:` ref (empty or whitespace-only name) must fail
+ /// closed the same as an unknown credential name — it must never be
+ /// treated as "no connection_ref" and silently sent unauthenticated
+ /// (Codex P2 finding).
+ #[test]
+ fn malformed_http_cred_name_fails_closed() {
+ let (_dir, store) = http_cred_store();
+ assert!(
+ resolve_http_credential(&store, Some("http_cred:")).is_err(),
+ "an empty http_cred name must fail closed, not fall through as no-op"
+ );
+ assert!(
+ resolve_http_credential(&store, Some("http_cred: ")).is_err(),
+ "a whitespace-only http_cred name must fail closed, not fall through as no-op"
+ );
+ }
+
+ /// No `connection_ref`, or a non-`http_cred:` prefix, injects nothing and
+ /// is not an error.
+ #[test]
+ fn no_http_cred_ref_injects_nothing() {
+ let (_dir, store) = http_cred_store();
+ assert!(resolve_http_credential(&store, None).unwrap().is_none());
+ assert!(
+ resolve_http_credential(&store, Some("composio:gmail:conn_1"))
+ .unwrap()
+ .is_none()
+ );
+ }
+
+ /// The secret is server-side-only: the approval-gate redaction (computed on
+ /// the pre-injection request) never contains it, and after injection it
+ /// lives ONLY in the outbound `Authorization` header.
+ #[test]
+ fn injected_secret_never_reaches_the_audit_redaction() {
+ let (_dir, store) = http_cred_store();
+ let secret = "sk_live_never_log_me";
+ store
+ .upsert(&HttpCredential::bearer("stripe", secret))
+ .unwrap();
+ let cred = resolve_http_credential(&store, Some("http_cred:stripe"))
+ .unwrap()
+ .unwrap();
+
+ let mut request = json!({ "method": "GET", "url": "https://api.example.com" });
+ // Pre-injection redaction — what the approval UI / audit trail sees.
+ let redacted = crate::openhuman::approval::redact_args(&request);
+ assert!(!serde_json::to_string(&redacted).unwrap().contains(secret));
+
+ inject_http_credential(&mut request, &cred).unwrap();
+ assert_eq!(
+ request["headers"]["Authorization"],
+ json!(format!("Bearer {secret}"))
+ );
+ }
+
+ // ── Phase 2: autonomy-tier gating of acting nodes ──────────────────────
+
+ fn policy(level: crate::openhuman::security::AutonomyLevel) -> SecurityPolicy {
+ SecurityPolicy {
+ autonomy: level,
+ ..SecurityPolicy::default()
+ }
+ }
+
+ /// The tier gate an `http_request` (Network-class) node calls: BLOCKED under
+ /// a read-only tier, and passed through (to the ApprovalGate) under
+ /// supervised/full.
+ #[test]
+ fn http_request_node_tier_gate_blocks_readonly_allows_higher() {
+ use crate::openhuman::security::AutonomyLevel;
+
+ let err = enforce_node_tier_gate(
+ &policy(AutonomyLevel::ReadOnly),
+ CommandClass::Network,
+ "http_request",
+ )
+ .expect_err("read-only must block a Network-class http_request node");
+ if let EngineError::Capability(msg) = err {
+ assert!(
+ msg.contains(POLICY_BLOCKED_MARKER),
+ "read-only block must carry the policy-blocked marker: {msg}"
+ );
+ } else {
+ panic!("expected EngineError::Capability for a blocked node");
+ }
+
+ // Supervised/full do not hard-block — they fall through to the
+ // ApprovalGate (which performs the Prompt round-trip).
+ assert!(enforce_node_tier_gate(
+ &policy(AutonomyLevel::Supervised),
+ CommandClass::Network,
+ "http_request"
+ )
+ .is_ok());
+ assert!(enforce_node_tier_gate(
+ &policy(AutonomyLevel::Full),
+ CommandClass::Network,
+ "http_request"
+ )
+ .is_ok());
+ }
+
+ /// The tier gate a `code` (Write-class) node calls: BLOCKED under read-only,
+ /// allowed under full, prompt-able (not blocked) under supervised.
+ #[test]
+ fn code_node_tier_gate_blocks_readonly_allows_full() {
+ use crate::openhuman::security::AutonomyLevel;
+
+ assert!(enforce_node_tier_gate(
+ &policy(AutonomyLevel::ReadOnly),
+ CommandClass::Write,
+ "code"
+ )
+ .is_err());
+ assert!(enforce_node_tier_gate(
+ &policy(AutonomyLevel::Supervised),
+ CommandClass::Write,
+ "code"
+ )
+ .is_ok());
+ assert!(
+ enforce_node_tier_gate(&policy(AutonomyLevel::Full), CommandClass::Write, "code")
+ .is_ok()
+ );
+ }
+
+ /// End-to-end at the adapter: an `http_request` node under a read-only tier
+ /// is refused BEFORE any network egress (the tier gate fires ahead of the
+ /// approval gate, credential resolution, and dispatch).
+ #[tokio::test]
+ async fn http_adapter_blocks_under_readonly_tier() {
+ use crate::openhuman::security::AutonomyLevel;
+
+ let (_dir, creds) = http_cred_store();
+ let http = OpenHumanHttp {
+ security: Arc::new(policy(AutonomyLevel::ReadOnly)),
+ http_config: HttpRequestConfig::default(),
+ http_creds: Arc::new(creds),
+ };
+
+ let request = json!({ "method": "GET", "url": "https://example.com" });
+ let err = http
+ .request(request, None)
+ .await
+ .expect_err("read-only http_request node must be blocked");
+ if let EngineError::Capability(msg) = err {
+ assert!(
+ msg.contains(POLICY_BLOCKED_MARKER),
+ "expected a policy-blocked refusal, got: {msg}"
+ );
+ } else {
+ panic!("expected EngineError::Capability");
+ }
+ }
+
+ // ── Codex P1: Prompt-tier decisions must escalate past a workflow's own
+ // require_approval=false default, never silently auto-allow ────────────
+
+ use crate::openhuman::agent::turn_origin::{AgentTurnOrigin, TrustedAutomationSource};
+
+ fn workflow_origin(job_id: &str, require_approval: bool) -> AgentTurnOrigin {
+ AgentTurnOrigin::TrustedAutomation {
+ job_id: job_id.to_string(),
+ source: TrustedAutomationSource::Workflow { require_approval },
+ }
+ }
+
+ /// A `Prompt` tier decision on a default (`require_approval: false`)
+ /// workflow trust root escalates to `require_approval: true` — the forced
+ /// human-in-the-loop round trip that closes the Codex P1 finding.
+ #[test]
+ fn prompt_decision_escalates_default_workflow_origin() {
+ let escalated = escalated_origin_for_prompt(
+ GateDecision::Prompt,
+ Some(workflow_origin("flow-1", false)),
+ )
+ .expect("a Prompt decision on require_approval=false must escalate");
+ assert!(matches!(
+ escalated,
+ AgentTurnOrigin::TrustedAutomation {
+ source: TrustedAutomationSource::Workflow {
+ require_approval: true
+ },
+ ..
+ }
+ ));
+ }
+
+ /// A flow that already opted into `require_approval: true` needs no
+ /// escalation — it's already forced through the parking flow.
+ #[test]
+ fn prompt_decision_does_not_re_escalate_already_gated_workflow() {
+ assert!(escalated_origin_for_prompt(
+ GateDecision::Prompt,
+ Some(workflow_origin("flow-1", true))
+ )
+ .is_none());
+ }
+
+ /// An `Allow` tier decision never escalates, regardless of the workflow's
+ /// `require_approval` toggle — Full-tier runs keep running unattended.
+ #[test]
+ fn allow_decision_never_escalates() {
+ assert!(escalated_origin_for_prompt(
+ GateDecision::Allow,
+ Some(workflow_origin("flow-1", false))
+ )
+ .is_none());
+ }
+
+ /// No scoped origin (or a non-Workflow origin) never escalates — there is
+ /// nothing to force through the workflow-specific parking flow.
+ #[test]
+ fn prompt_decision_does_not_escalate_without_a_workflow_origin() {
+ assert!(escalated_origin_for_prompt(GateDecision::Prompt, None).is_none());
+ }
+}
diff --git a/src/openhuman/tinyflows/tests.rs b/src/openhuman/tinyflows/tests.rs
index c57f47061..8c1c2e8aa 100644
@@ -13,274 +13,282 @@
//! rejection both surface as `EngineError::Capability` (proving the adapter
//! correctly propagates `HttpRequestTool`'s real security behavior), and
//! - the engine smoke test drives `trigger -> http_request` against a
//! deterministically-blocked loopback URL with `on_error: continue`, which
//! exercises the full real stack (build_capabilities -> engine -> compiled
//! graph -> `OpenHumanHttp` -> real `HttpRequestTool` -> SSRF guard ->
//! `EngineError::Capability` -> the crate's `on_error: continue` policy ->
//! error item) without any network dependency.
use std::sync::Arc;
use serde_json::json;
use tempfile::TempDir;
use tinyflows::caps::{CodeLanguage, CodeRunner, HttpClient, StateStore, ToolInvoker};
use tinyflows::model::{Edge, Node, NodeKind, WorkflowGraph};
use crate::openhuman::config::Config;
use crate::openhuman::security::SecurityPolicy;
use super::build_capabilities;
use super::caps::{FlowStateStore, OpenHumanCode, OpenHumanHttp, OpenHumanTools};
fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
action_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
Arc::new(config)
}
fn node(id: &str, kind: NodeKind, config: serde_json::Value) -> Node {
Node {
id: id.to_string(),
kind,
type_version: 1,
name: id.to_string(),
config,
ports: Vec::new(),
position: None,
}
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from_node: from.to_string(),
from_port: "main".to_string(),
to_node: to.to_string(),
to_port: "main".to_string(),
}
}
// ── build_capabilities smoke ────────────────────────────────────────────
#[test]
fn build_capabilities_constructs_every_slot_without_panicking() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
// Purely a construction smoke test — no capability is invoked here.
let _caps = build_capabilities(config, "test:build");
}
// ── HTTP adapter ─────────────────────────────────────────────────────────
fn http_adapter(allowed_domains: Vec<String>) -> OpenHumanHttp {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
&config.action_dir,
));
OpenHumanHttp {
security,
http_config: crate::openhuman::config::HttpRequestConfig {
allowed_domains,
..Default::default()
},
+ http_creds: Arc::new(
+ crate::openhuman::credentials::HttpCredentialsStore::from_config(&config),
+ ),
}
}
#[tokio::test]
async fn http_adapter_blocks_loopback_host_as_capability_error() {
let adapter = http_adapter(vec![]); // open allowlist mode
let err = adapter
.request(
json!({ "method": "GET", "url": "http://127.0.0.1:1/" }),
None,
)
.await
.expect_err("loopback host must be blocked by the SSRF guard");
let msg = err.to_string();
assert!(
msg.to_lowercase().contains("private") || msg.to_lowercase().contains("local"),
"expected an SSRF-guard message, got: {msg}"
);
}
#[tokio::test]
async fn http_adapter_rejects_host_outside_strict_allowlist() {
let adapter = http_adapter(vec!["example.com".to_string()]);
let err = adapter
.request(
json!({ "method": "GET", "url": "https://not-allowed.test/" }),
None,
)
.await
.expect_err("host outside the strict allowlist must be rejected");
assert!(
err.to_string().contains("not-allowed.test")
|| err.to_string().to_lowercase().contains("allowed"),
"expected an allowlist rejection message, got: {err}"
);
}
// ── StateStore adapter ───────────────────────────────────────────────────
#[tokio::test]
async fn flow_state_store_round_trips_and_is_namespace_scoped() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let ns1 = FlowStateStore {
config: config.clone(),
namespace: "ns1".to_string(),
};
let ns2 = FlowStateStore {
config: config.clone(),
namespace: "ns2".to_string(),
};
assert!(ns1.load("k").await.unwrap().is_none());
ns1.store("k", json!({ "v": 1 })).await.unwrap();
assert_eq!(ns1.load("k").await.unwrap(), Some(json!({ "v": 1 })));
// A different namespace never sees ns1's value.
assert!(ns2.load("k").await.unwrap().is_none());
// Overwrite.
ns1.store("k", json!(2)).await.unwrap();
assert_eq!(ns1.load("k").await.unwrap(), Some(json!(2)));
}
// ── Engine smoke: real seam end to end ───────────────────────────────────
#[tokio::test]
async fn engine_run_drives_trigger_to_http_request_through_the_real_seam() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let caps = build_capabilities(config, "test:smoke");
// A deterministically-blocked loopback URL with `on_error: continue` so
// the run completes even though the (real, SSRF-guarded) HTTP adapter
// necessarily rejects it — see the module doc for why a real network
// round-trip isn't testable here.
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger, serde_json::Value::Null),
node(
"http",
NodeKind::HttpRequest,
json!({ "method": "GET", "url": "http://127.0.0.1:1/", "on_error": "continue" }),
),
],
edges: vec![edge("t", "http")],
..Default::default()
};
let compiled = tinyflows::compiler::compile(&graph).expect("compile");
let outcome = tinyflows::engine::run(&compiled, json!({ "seed": 1 }), &caps)
.await
.expect("run should complete (on_error: continue)");
assert!(outcome.pending_approvals.is_empty());
assert_eq!(
outcome.output["nodes"]["http"]["items"][0]["json"]["error"]["node"],
json!("http")
);
}
// ── Code adapter ──────────────────────────────────────────────────────────
/// Requires `node` on `PATH`. Ignored by default (per the B1 test plan);
/// run explicitly with `cargo test -- --ignored` on a host with Node
/// installed.
#[tokio::test]
#[ignore = "requires a `node` binary on PATH"]
async fn code_adapter_javascript_passthrough_round_trips_json() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
- let runner = OpenHumanCode { config };
+ let security = Arc::new(SecurityPolicy::from_config(
+ &config.autonomy,
+ &config.workspace_dir,
+ &config.action_dir,
+ ));
+ let runner = OpenHumanCode { config, security };
let input = json!([{ "json": { "n": 7 } }]);
let result = runner
.run(CodeLanguage::JavaScript, "return input;", input.clone())
.await
.expect("javascript passthrough should succeed when node is present");
assert_eq!(result, input);
}
// ── Tool curation / scope + connection_ref (issue B2) ─────────────────────
//
// No `ApprovalGate` is installed in this test binary (see the module doc on
// `flows::bus`'s tests and the trust-model tests in `approval::gate` for the
// gate-level behavior) — these tests exercise the *curation* gate, which is
// independent of the approval gate and runs first, so they stay deterministic
// without any global state.
fn tools_adapter(config: Arc<Config>) -> OpenHumanTools {
OpenHumanTools { config }
}
#[tokio::test]
async fn tools_invoke_rejects_a_non_curated_slug_for_a_known_toolkit() {
let tmp = TempDir::new().unwrap();
let tools = tools_adapter(test_config(&tmp));
// "gmail" has a curated catalog; this action is not in it, so curation
// must reject regardless of the user's read/write/admin scope prefs.
let err = tools
.invoke("GMAIL_NOT_A_REAL_CURATED_ACTION", json!({}), None)
.await
.expect_err("a non-curated action for a curated toolkit must be rejected");
let msg = err.to_string();
assert!(
msg.contains("tool not permitted"),
"expected a curation rejection message, got: {msg}"
);
assert!(msg.contains("GMAIL_NOT_A_REAL_CURATED_ACTION"));
}
#[tokio::test]
async fn tools_invoke_rejects_an_unrecognized_toolkit_slug() {
// Issue B2 finding #2 (deny-by-default): a made-up toolkit prefix that
// isn't in any curated catalog must be rejected — not passed through on
// a permissive "unknown toolkit" heuristic. Live testing confirmed this
// used to reach Composio (and only failed there for lack of a signed-in
// session), which is not a hard allowlist.
let tmp = TempDir::new().unwrap();
let tools = tools_adapter(test_config(&tmp));
let err = tools
.invoke("madeupkit_dostuff", json!({}), None)
.await
.expect_err("an unrecognized toolkit slug must be rejected by curation");
let msg = err.to_string();
assert!(
msg.contains("tool not permitted"),
"expected a curation rejection message, got: {msg}"
);
assert!(msg.contains("madeupkit_dostuff"));
}
#[tokio::test]
async fn tools_invoke_rejects_a_prefix_less_slug() {
// "noop" has no curated catalog (`catalog_for_toolkit` returns `None`
// for the single-segment "toolkit" `toolkit_from_slug` degrades it to),
// so the hard allowlist in `is_curated_flow_tool` rejects it outright —
// unlike the general agent tool-call path's `is_action_visible_with_pref`,
// which falls back to the permissive `classify_unknown` heuristic and
// would let this slug through.
let tmp = TempDir::new().unwrap();
let tools = tools_adapter(test_config(&tmp));
let err = tools
.invoke("noop", json!({}), None)
.await
.expect_err("a prefix-less/unrecognized slug must be rejected by curation");
assert!(
err.to_string().contains("tool not permitted"),
"expected a curation rejection message, got: {err}"
diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs
index 548c13ace..1ce3a645d 100644
@@ -12653,160 +12653,222 @@ async fn json_rpc_flows_validate_reports_warnings_and_errors() {
.and_then(Value::as_array)
.expect("errors")
.is_empty(),
"a structurally valid webhook graph has no errors"
);
let warnings = v
.get("warnings")
.and_then(Value::as_array)
.expect("warnings");
assert_eq!(
warnings.len(),
1,
"webhook trigger emits exactly one warning"
);
assert!(
warnings[0]
.as_str()
.is_some_and(|w| w.contains("does not fire automatically")),
"webhook warning must explain the trigger will not fire on its own, got: {warnings:?}"
);
// 2. Schedule trigger — fires automatically → no warning.
let schedule_graph = json!({
"name": "scheduled-flow",
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Trigger", "config": { "trigger_kind": "schedule", "schedule": "0 9 * * *" } }
],
"edges": []
});
let validate_sched = post_json_rpc(
&rpc_base,
9502,
"openhuman.flows_validate",
json!({ "graph": schedule_graph }),
)
.await;
let vs = peel_logs_envelope(assert_no_jsonrpc_error(&validate_sched, "flows_validate"));
assert_eq!(vs.get("valid").and_then(Value::as_bool), Some(true));
assert!(
vs.get("warnings")
.and_then(Value::as_array)
.expect("warnings")
.is_empty(),
"a schedule trigger fires automatically — no unfired-kind warning"
);
// 3. No trigger node — structurally invalid.
let invalid_graph = json!({
"name": "no-trigger",
"nodes": [ { "id": "a", "kind": "output_parser", "name": "A" } ],
"edges": []
});
let validate_bad = post_json_rpc(
&rpc_base,
9503,
"openhuman.flows_validate",
json!({ "graph": invalid_graph }),
)
.await;
let vb = peel_logs_envelope(assert_no_jsonrpc_error(&validate_bad, "flows_validate"));
assert_eq!(vb.get("valid").and_then(Value::as_bool), Some(false));
assert!(
!vb.get("errors")
.and_then(Value::as_array)
.expect("errors")
.is_empty(),
"a graph without a trigger must report a structural error"
);
assert!(
vb.get("warnings")
.and_then(Value::as_array)
.expect("warnings")
.is_empty(),
"an invalid graph reports errors, not warnings"
);
api_join.abort();
rpc_join.abort();
}
+/// `openhuman.flows_list_connections` (PHASE 2): the connection picker source.
+/// Aggregates Composio connected accounts + stored HTTP credentials into a flat
+/// list of `connection_ref` + display + kind — and NEVER any secret material.
+///
+/// We seed one named HTTP credential (a bearer token) through the same
+/// host-side store the RPC reads, then assert the RPC surfaces it as
+/// `http_cred:<name>` with `kind = "http"` and that the token value never
+/// appears anywhere in the RPC payload. The Composio half is exercised for
+/// fault-tolerance: the mock upstream has no connected-accounts route, so the
+/// Composio source fails and is tolerated (the RPC still returns the HTTP half
+/// rather than erroring).
+#[tokio::test]
+async fn json_rpc_flows_list_connections_aggregates_secret_free() {
+ let _env_lock = json_rpc_e2e_env_lock();
+ let (rpc_base, _tmp, api_join, rpc_join, _guards) = boot_flows_rpc_env().await;
+
+ // Seed an HTTP credential through the same encrypted-at-rest store the op
+ // reads (config resolves under the guarded HOME set by boot_flows_rpc_env).
+ let seed_config = openhuman_core::openhuman::config::load_config_with_timeout()
+ .await
+ .expect("load config to seed http_cred");
+ const SECRET: &str = "sk_live_flows_list_connections_seed";
+ openhuman_core::openhuman::credentials::HttpCredentialsStore::from_config(&seed_config)
+ .upsert(&openhuman_core::openhuman::credentials::HttpCredential::bearer("stripe", SECRET))
+ .expect("seed http_cred");
+
+ let resp = post_json_rpc(
+ &rpc_base,
+ 9330,
+ "openhuman.flows_list_connections",
+ json!({}),
+ )
+ .await;
+ let raw = assert_no_jsonrpc_error(&resp, "flows_list_connections");
+
+ // The seeded secret must never appear anywhere in the RPC response.
+ let raw_str = raw.to_string();
+ assert!(
+ !raw_str.contains(SECRET),
+ "secret leaked into flows_list_connections payload: {raw_str}"
+ );
+
+ let connections = peel_logs_envelope(raw)
+ .as_array()
+ .expect("connections is an array")
+ .clone();
+
+ let stripe = connections
+ .iter()
+ .find(|c| c.get("connection_ref").and_then(Value::as_str) == Some("http_cred:stripe"))
+ .expect("seeded http_cred surfaced in picker");
+ assert_eq!(stripe.get("kind").and_then(Value::as_str), Some("http"));
+ assert_eq!(stripe.get("scheme").and_then(Value::as_str), Some("bearer"));
+ assert!(
+ stripe.get("display").and_then(Value::as_str).is_some(),
+ "http_cred entry must carry a display label"
+ );
+
+ api_join.abort();
+ rpc_join.abort();
+}
+
/// Task 4 / #3090: when a web-chat request is sent with
/// `speak_reply: true`, `run_chat_task` should drive the agent's final text
/// through `voice::reply_speech::synthesize_reply` after the turn completes.
///
/// We activate the [`reply_speech::test_seam`] short-circuit via the
/// `OPENHUMAN_TEST_REPLY_SPEECH_SEAM` env var so the call is recorded
/// without contacting the ElevenLabs proxy.
#[test]
fn json_rpc_channel_web_chat_with_speak_reply_invokes_reply_speech() {
run_json_rpc_e2e_on_agent_stack(
"json_rpc_speak_reply_e2e",
json_rpc_channel_web_chat_with_speak_reply_invokes_reply_speech_inner,
);
}
async fn json_rpc_channel_web_chat_with_speak_reply_invokes_reply_speech_inner() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
// Activate the reply_speech test seam so synthesize_reply records and
// short-circuits instead of calling the hosted backend.
let _seam_guard = EnvVarGuard::set(
openhuman_core::openhuman::voice::reply_speech::TEST_SEAM_ENV,
"1",
);
openhuman_core::openhuman::voice::reply_speech::test_seam::clear();
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// Authenticate so the agent loop has a session token available.
let store = post_json_rpc(
&rpc_base,
9300,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
let client_id = "ptt-e2e-client";
let thread_id = "ptt-e2e-thread";
let events_url = format!("{}/events?client_id={}", rpc_base, client_id);
let sse_task = tokio::spawn(async move { read_terminal_web_chat_event(&events_url).await });
// PTT-style chat send: speak_reply=true, source=ptt, session_id=1.
let web_chat = post_json_rpc(
&rpc_base,
9301,
"openhuman.channel_web_chat",
json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "Hello from PTT",
"model_override": "e2e-mock-model",
"speak_reply": true,
"source": "ptt",
"session_id": 1,
}),
)
.await;
let web_chat_result = assert_no_jsonrpc_error(&web_chat, "channel_web_chat");