feat: let flow builder operators build AI agents (#11898)

* feat: let flow builder operators build AI agents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: check linked agents' bound tools and type, validate on the write's transaction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: bound the linked-agent walk by sorting bound tool ids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let builders bind only the agent tool inputs left to the model

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: check every agent tool sharing a bound id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show builders the agent's own tool inputs read-only and name agents in the setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: show the agent's own tool inputs with the read-only step viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: give the agent-set inputs heading the input label style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: label the agent's fixed tool inputs static inputs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: address cubic review on agent builder rights

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: check agent writes against the role's flows capability

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: name the flows capability as the agent builder right

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: gate agent evals on the role's code capability

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: move the composed-flow check into windmill-api-auth

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-23 15:05:20 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent da443d2fb2
commit e5dedd147e
27 changed files with 763 additions and 167 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ Open-source platform for internal tools, workflows, API integrations, background
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
fields and a jsonb merge rather than a serde default
- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace settings that let
operators compose flows and full-code apps, what the composition check must cover, which
operators compose flows, AI agents and full-code apps, what the composition check must cover, which
raw-app endpoint stays closed on purpose, and why either right costs a full seat
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value: sqlx::types::Json<serde_json::Value>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c"
}
+55
View File
@@ -0,0 +1,55 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::{
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
};
/// Only writes of an agent are checked for operators, so a linked step must refuse a path whose
/// resource is no longer an agent rather than run the `tools` it now holds.
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn linked_agent_step_refuses_a_resource_of_another_type(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type)
VALUES ('test-workspace', 'u/test-user/agent', $1, 'object')",
)
.bind(
json!({"tools": [{"id": "t", "value": {"tool_type": "flowmodule",
"type": "rawscript", "language": "bun", "input_transforms": {},
"content": "export async function main() { return 1 }"}}]}),
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let flow: FlowValue = serde_json::from_value(json!({"modules": [{"id": "agent", "value": {
"type": "aiagent", "tools": [], "agent": "$res:u/test-user/agent",
"input_transforms": {"user_message": {"type": "static", "value": "hi"}}
}}]}))?;
let id = RunJob::from(JobPayload::RawFlow {
value: flow,
path: Some("u/test-user/linked".into()),
restarted_from: None,
})
.push(&db)
.await;
let notifications = listen_for_completed_jobs(&db).await;
tokio::time::timeout(
Duration::from_secs(45),
in_test_worker(&db, notifications.find(&id), server.addr.port()),
)
.await?;
let job = completed_job(id, &db).await;
server.close().await?;
assert!(!job.success);
assert!(
format!("{:?}", job.result).contains("not an AI agent"),
"{:?}",
job.result
);
Ok(())
}
+171
View File
@@ -0,0 +1,171 @@
//! Authorizing what a restricted role composes: the references a value-only walk
//! (`check_flow_is_composition_only`) collects, checked against the caller's own permissions.
use sqlx::{Postgres, Transaction};
use windmill_common::{
error::{self, Error},
flows::FlowValue,
roles::AuthorLevel,
workspaces::{check_operator_can_build, BuilderKind},
DB,
};
use crate::{get_scope_tags, is_super_admin_authed, ApiAuthed};
/// `validate_operator_composed_flow` on a transaction the caller already holds, so a write that
/// has one open does not wait on a second connection. It must have been begun as the caller
/// (`UserDB::begin`): its row-level security is the check.
pub async fn authorize_composed_flow(
value: &FlowValue,
flow_tag: &Option<String>,
authed: &ApiAuthed,
db: &DB,
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
) -> error::Result<()> {
let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?;
// A builder links only an agent it can read, and binds only the tool inputs it leaves open.
for agent in &refs.agents {
let path = &agent.path;
let value = sqlx::query_scalar!(
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
w_id,
path,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| {
Error::PermissionDenied(format!(
"Agent {path} does not exist or is not readable by you"
))
})?;
if agent.bound_inputs.is_empty() {
continue;
}
let tools: Vec<windmill_common::flows::AgentTool> = value
.and_then(|v| v.0.get("tools").cloned())
.map(serde_json::from_value)
.transpose()
.map_err(|e| Error::BadRequest(format!("Invalid tools in agent {path}: {e}")))?
.unwrap_or_default();
windmill_common::flows::check_bound_agent_inputs_are_open(
path,
&tools,
&agent.bound_inputs,
)?;
}
// A tag is how a step picks the worker group it runs on: unauthorized, a builder could route
// a job onto a privileged one.
refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty()));
if !refs.tags.is_empty() {
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = is_super_admin_authed(db, authed).await?;
for tag in &refs.tags {
windmill_common::jobs::check_tag_available_for_workspace_internal(
db,
w_id,
tag,
None,
std::future::ready(w_id.to_string()),
is_super_admin,
get_scope_tags(authed),
)
.await?;
}
}
if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
return Ok(());
}
// A flow can step through the same script thirty times; this runs on every write, preview and
// dependency job.
refs.runnables.sort();
refs.runnables.dedup();
refs.pinned_scripts
.sort_by_key(|(path, hash)| (path.clone(), hash.0));
refs.pinned_scripts
.dedup_by_key(|(path, hash)| (path.clone(), hash.0));
// The worker resolves a step's path with the root DB handle and runs it as that runnable's
// `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS
// on this transaction is the check.
for (is_flow, path) in &refs.runnables {
let readable = if *is_flow {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
}
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if !readable {
return Err(Error::PermissionDenied(format!(
"{} {path} does not exist or is not readable by you",
if *is_flow { "Flow" } else { "Script" }
)));
}
}
// A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable
// path paired with another script's hash would still run that other script.
for (path, hash) in &refs.pinned_scripts {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
w_id,
path,
hash.0,
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::PermissionDenied(format!(
"Version {hash} is not a readable version of {path}"
)));
}
}
Ok(())
}
/// What a restricted role must pass to store an `ai_agent` resource, deployed or as a draft. Its
/// tools run in the agent's own runs and in every flow step linking it, so it is held to the rules
/// of a composed flow, under the same capability. `tx` is the write's own, begun as the caller.
pub async fn validate_operator_agent(
value: &serde_json::Value,
authed: &ApiAuthed,
db: &DB,
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
) -> error::Result<()> {
if check_operator_can_build(db, w_id, authed, BuilderKind::Flows, "write AI agents").await?
== AuthorLevel::Full
{
return Ok(());
}
let tools = value
.get("tools")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
let flow: FlowValue = serde_json::from_value(serde_json::json!({"modules": [{
"id": "agent",
"value": {"type": "aiagent", "input_transforms": {}, "tools": tools}
}]}))
.map_err(|e| Error::BadRequest(format!("Invalid agent tools: {e}")))?;
authorize_composed_flow(&flow, &None, authed, db, tx, w_id)
.await
.map_err(|e| match e {
Error::PermissionDenied(msg) => {
Error::PermissionDenied(msg.replace("edit this flow", "edit this agent"))
}
e => e,
})
}
+1
View File
@@ -7,6 +7,7 @@
*/
pub mod auth;
pub mod composed;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
+6 -80
View File
@@ -16,8 +16,8 @@ use axum::{
};
use windmill_api_auth::{
auth::{list_tokens_internal, TruncatedTokenWithEmail},
build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders,
require_owner_of_path, ApiAuthed,
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
ApiAuthed,
};
use windmill_common::roles::AuthorLevel;
use windmill_common::workspaces::{
@@ -643,85 +643,11 @@ pub async fn validate_operator_composed_flow(
user_db: &UserDB,
w_id: &str,
) -> error::Result<()> {
let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?;
// A tag is how a step picks the worker group it runs on: unauthorized, a builder could route
// a job onto a privileged one.
refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty()));
if !refs.tags.is_empty() {
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
for tag in &refs.tags {
windmill_common::jobs::check_tag_available_for_workspace_internal(
db,
w_id,
tag,
None,
std::future::ready(w_id.to_string()),
is_super_admin,
get_scope_tags(authed),
)
.await?;
}
}
if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
return Ok(());
}
// A flow can step through the same script thirty times; this runs on every write, preview and
// dependency job.
refs.runnables.sort();
refs.runnables.dedup();
refs.pinned_scripts
.sort_by_key(|(path, hash)| (path.clone(), hash.0));
refs.pinned_scripts
.dedup_by_key(|(path, hash)| (path.clone(), hash.0));
// The worker resolves a step's path with the root DB handle and runs it as that runnable's
// `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS
// on this transaction is the check.
let mut tx = user_db.clone().begin(authed).await?;
for (is_flow, path) in &refs.runnables {
let readable = if *is_flow {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
}
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if !readable {
return Err(Error::PermissionDenied(format!(
"{} {path} does not exist or is not readable by you",
if *is_flow { "Flow" } else { "Script" }
)));
}
}
// A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable
// path paired with another script's hash would still run that other script.
for (path, hash) in &refs.pinned_scripts {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
w_id,
path,
hash.0,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::PermissionDenied(format!(
"Version {hash} is not a readable version of {path}"
)));
}
}
windmill_api_auth::composed::authorize_composed_flow(
value, flow_tag, authed, db, &mut tx, w_id,
)
.await?;
tx.commit().await?;
Ok(())
}
@@ -325,5 +325,155 @@ async fn test_operator_builder_flows_boundary(db: Pool<Postgres>) -> anyhow::Res
resp.text().await?
);
check_builder_agents(&db, &api, &c).await
}
/// An agent is a resource an operator may otherwise write, and its stored tools run as they are,
/// so only the flow right opens it, and every write of one, drafts included, is checked like a
/// composed flow. Part of the boundary test: the right is read through a process-global cache
/// keyed by workspace id, which a second test in this binary would race.
async fn check_builder_agents(
db: &Pool<Postgres>,
api: &str,
c: &reqwest::Client,
) -> anyhow::Result<()> {
let tool = |mut value: serde_json::Value| {
value["tool_type"] = json!("flowmodule");
value["input_transforms"] = json!({});
json!({"tools": [{"id": "t", "summary": "t", "value": value}]})
};
let composed = tool(json!({"type": "script", "path": "u/operator/some_script"}));
let inline = tool(json!({"type": "rawscript", "content": "echo x", "language": "bash"}));
let agent = |path: &str, value: &serde_json::Value| json!({"path": path, "resource_type": "ai_agent", "value": value});
set_builder(db, false).await?;
let resp = c
.post(format!("{api}/resources/create"))
.json(&agent("u/operator/a1", &composed))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"an operator without the builder right must not write an agent"
);
set_builder(db, true).await?;
let resp = c
.post(format!("{api}/resources/create"))
.json(&agent("u/operator/a1", &inline))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not save an agent with an inline tool"
);
let resp = c
.post(format!("{api}/resources/create"))
.json(&agent("u/operator/a1", &composed))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
let resp = c
.post(format!("{api}/resources/update_value/u/operator/a1"))
.json(&json!({"value": inline}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not swap an agent's tools for inline code"
);
// A draft of a deployed agent may leave its type to the deployed row.
let resp = c
.post(format!("{api}/drafts/update/resource/u/operator/a1"))
.json(&json!({"value": {"args": inline, "description": ""}, "force": true}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not draft an agent with an inline tool"
);
// Deploying a draft over an agent keeps the row's type, whatever type the draft names.
let resp = c
.post(format!("{api}/drafts/update/resource/u/operator/a1"))
.json(&json!({"value": {"args": inline, "description": "", "resource_type": "object"},
"force": true}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not draft inline tools over an agent under another type"
);
let linking = |path: &str, agent: &str| {
json!({"path": path, "summary": "", "description": "", "schema": {},
"value": {"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [], "agent": format!("$res:{agent}")}}]}})
};
let resp = c
.post(format!("{api}/flows/create"))
.json(&linking("u/operator/agent_f1", "u/operator/a1"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to link a readable agent: {}",
resp.text().await?
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&linking("u/operator/agent_f2", "u/alice/agent"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not link an agent it cannot read"
);
// A developer's agent may run inline code, and linking it runs it as authored. A step may bind
// the inputs the agent leaves to the model, never one its author set.
let mut dev_agent = inline.clone();
dev_agent["tools"][0]["value"]["input_transforms"] = json!({
"set": {"type": "static", "value": "fixed"}, "open": {"type": "ai"}});
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by)
VALUES ($1, 'u/operator/dev_agent', $2, 'ai_agent', 'alice')",
)
.bind(WS)
.bind(&dev_agent)
.execute(db)
.await?;
let binding = |path: &str, input: &str| {
let mut flow = linking(path, "u/operator/dev_agent");
flow["value"]["modules"][0]["value"]["tool_inputs"] =
json!({"t": {input: {"type": "static", "value": "bound"}}});
flow
};
let resp = c
.post(format!("{api}/flows/create"))
.json(&binding("u/operator/agent_f4", "open"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to bind an input the agent leaves open: {}",
resp.text().await?
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&binding("u/operator/agent_f3", "set"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not override an input the agent's author set"
);
Ok(())
}
+37
View File
@@ -499,6 +499,43 @@ async fn update_draft(
}
}
// An agent draft is held to the rules of its deploy, as a flow draft is: a developer who
// tests it in the agent editor runs its tools as themselves.
if caps.flows != AuthorLevel::Full && kind == UserDraftItemKind::Resource {
if let Some(value) = &req.value {
#[derive(Deserialize)]
struct ResourceDraft {
#[serde(default)]
args: serde_json::Value,
resource_type: Option<String>,
}
let draft: ResourceDraft = serde_json::from_str(&strip_json_nul(value.0.get()))
.map_err(|e| Error::BadRequest(format!("Invalid resource draft: {e}")))?;
// Whatever type the draft names, deploying it over a deployed agent keeps the row's type.
let is_agent = draft.resource_type.as_deref() == Some("ai_agent")
|| sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
&w_id,
path,
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if is_agent {
let mut tx = user_db.clone().begin(&authed).await?;
windmill_api_auth::composed::validate_operator_agent(
&draft.args,
&authed,
&db,
&mut tx,
&w_id,
)
.await?;
tx.commit().await?;
}
}
}
// A raw-app draft carries the app value and its policy side by side.
if caps.apps == AuthorLevel::Compose && kind == UserDraftItemKind::RawApp {
if let Some(value) = &req.value {
+24
View File
@@ -110,6 +110,30 @@ impl AuthedClient {
make_basic_get_request(self, &url, None, Some("decoding resource value as json")).await
}
/// The value of the `ai_agent` resource at `path`, refused when the resource there is of another
/// type. Only writes of an agent are checked for operators, so a step linking a path must not run
/// tools from a resource retyped, or recreated as another type, since the link was approved.
pub async fn get_agent_value(&self, path: &str) -> anyhow::Result<serde_json::Value> {
#[derive(serde::Deserialize)]
struct Resource {
resource_type: String,
value: Option<serde_json::Value>,
}
let url = format!(
"{}/api/w/{}/resources/get/{}",
self.base_internal_url, self.workspace, path
);
let resource: Resource =
make_basic_get_request(self, &url, None, Some("decoding the agent resource")).await?;
if resource.resource_type != "ai_agent" {
anyhow::bail!(
"{path} is a {} resource, not an AI agent",
resource.resource_type
);
}
Ok(resource.value.unwrap_or_default())
}
pub async fn get_variable_value(&self, path: &str) -> anyhow::Result<String> {
let url = format!(
"{}/api/w/{}/variables/get_value/{}",
+117 -17
View File
@@ -252,8 +252,8 @@ pub async fn resolve_modules(
/// modules, every branch, and the `tools` of an AI agent step.
///
/// Returns what the caller still has to authorize against its own permissions, which this
/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the
/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not
/// value-only walk cannot: every runnable and linked agent the steps reference, the worker tags
/// they pin, and the `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not
/// already settled by the walk.
pub fn check_flow_is_composition_only(value: &FlowValue) -> Result<ComposedFlowRefs, Error> {
let mut refs = ComposedFlowRefs::default();
@@ -276,6 +276,65 @@ pub struct ComposedFlowRefs {
pub runnables: Vec<(bool, String)>,
/// Version-pinned script steps, as `(path, hash)`.
pub pinned_scripts: Vec<(String, ScriptHash)>,
/// The `ai_agent` resources that agent steps link.
pub agents: Vec<LinkedAgent>,
}
/// An agent step's link, with the tool inputs the step binds, keyed by tool id.
pub struct LinkedAgent {
pub path: String,
pub bound_inputs: Vec<(String, Vec<String>)>,
}
/// A builder's step may bind only the tool inputs its linked agent leaves open, passing them what
/// it could pass a deployed runnable's inputs. An input the agent fills is part of what its author
/// wrote, and the step's binding would replace it at run time. Every tool sharing an id is
/// checked, as the overlay binds them all.
pub fn check_bound_agent_inputs_are_open(
agent: &str,
tools: &[AgentTool],
bound_inputs: &[(String, Vec<String>)],
) -> Result<(), Error> {
for (tool_id, keys) in bound_inputs {
for tool in tools.iter().filter(|t| &t.id == tool_id) {
check_tool_inputs_are_open(agent, tool_id, &tool.value, keys)?;
}
}
Ok(())
}
fn check_tool_inputs_are_open(
agent: &str,
tool_id: &str,
value: &ToolValue,
keys: &[String],
) -> Result<(), Error> {
let ToolValue::FlowModule(value) = value else {
return Ok(());
};
let input_transforms = match value {
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. }
| FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
// Fail closed on any other kind, as the worker's `overlay_tool_inputs` skips them.
_ => {
return Err(Error::PermissionDenied(format!(
"Tool {tool_id} of agent {agent} takes no inputs a builder can set"
)))
}
};
match keys
.iter()
.find(|k| input_transforms.get(*k).is_some_and(|t| t.is_completed()))
{
Some(key) => Err(Error::PermissionDenied(format!(
"Input {key} of tool {tool_id} is set by agent {agent}, so a builder cannot \
change it. Bind only the inputs the agent leaves to the model."
))),
None => Ok(()),
}
}
fn check_module_is_composition_only(
@@ -343,12 +402,20 @@ fn check_module_value_is_composition_only(
FlowModuleValue::BranchAll { branches, .. } => {
check_branches_are_composition_only(branches, id, refs)?
}
FlowModuleValue::AIAgent { tools, tag, agent, .. } => {
// A linked agent resolves its tools from an `ai_agent` resource at run time, and
// operators may write resources, so those tools are outside this check: the list can
// be swapped for a raw script after the flow is deployed.
if agent.is_some() {
return refuse("links an AI agent resource, whose tools live outside the flow");
FlowModuleValue::AIAgent { tools, tag, agent, tool_inputs, .. } => {
// A linked agent's tools are resolved from its resource at run time, outside this
// walk. That is safe only because an operator's every write of an agent passes this
// same check (`validate_operator_agent`), as a developer's agent is code they wrote.
if let Some(agent) = agent {
let path = agent
.trim_start_matches("$res:")
.trim_start_matches("res://");
let bound_inputs = tool_inputs
.iter()
.map(|(tool, inputs)| (tool.clone(), inputs.keys().cloned().collect()))
.collect();
refs.agents
.push(LinkedAgent { path: path.to_string(), bound_inputs });
}
push_tag(tag);
for tool in tools {
@@ -477,15 +544,48 @@ mod tests {
#[test]
fn composition_check_rejects_code_reachable_through_an_ai_agent() {
for value in [
serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule",
"type": "rawscript", "content": "x", "language": "bun"}}]}}]}),
serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [], "agent": "$res:f/x/agent"}}]}),
] {
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
let value = serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule",
"type": "rawscript", "content": "x", "language": "bun"}}]}}]});
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
/// A builder may link only an agent they can read, which the walk cannot know: it has to
/// surface the path for the caller to check.
#[test]
fn composition_check_reports_linked_agents() {
let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [{
"id": "a", "value": {"type": "aiagent", "input_transforms": {}, "tools": [],
"agent": "$res:f/x/agent", "tool_inputs": {"t": {}}}}]})))
.unwrap();
assert_eq!(refs.agents[0].path, "f/x/agent");
assert_eq!(refs.agents[0].bound_inputs, vec![("t".to_string(), vec![])]);
}
#[test]
fn bound_agent_inputs_must_be_left_to_the_model() {
// Two tools share the id: the overlay binds both, so the second's `query` counts.
let tools: Vec<AgentTool> = serde_json::from_value(serde_json::json!([
{"id": "t", "value": {"tool_type": "flowmodule", "type": "rawscript", "content": "x",
"language": "bun", "input_transforms": {}}},
{"id": "t", "value": {"tool_type": "flowmodule", "type": "rawscript", "content": "x",
"language": "bun", "input_transforms": {
"query": {"type": "static", "value": "SELECT 1"},
"limit": {"type": "ai"},
"note": {"type": "static", "value": null}}}}]))
.unwrap();
let bind = |keys: &[&str]| {
check_bound_agent_inputs_are_open(
"f/x/agent",
&tools,
&[(
"t".to_string(),
keys.iter().map(|k| k.to_string()).collect(),
)],
)
};
assert!(bind(&["limit", "note", "absent"]).is_ok());
assert!(bind(&["limit", "query"]).is_err());
}
#[test]
+6
View File
@@ -2489,6 +2489,9 @@ async fn update_resource_value(
/// `run_on_behalf_of` is set: then the writer's own, unless one allowed to keep the one the value
/// names asks to, and a folder's default for a new agent. The value is written by anyone who can
/// write the agent, so the key it arrives with is only a request.
///
/// Every write of an agent passes here, so this is also where an operator's is held to the
/// composed-flow rules: a flow step linking the agent runs its tools unchecked.
async fn resolve_agent_on_behalf_of(
tx: &mut Transaction<'_, Postgres>,
authed: &ApiAuthed,
@@ -2499,6 +2502,9 @@ async fn resolve_agent_on_behalf_of(
preserve: bool,
created: bool,
) -> Result<()> {
if authed.is_operator {
windmill_api_auth::composed::validate_operator_agent(value, authed, db, tx, w_id).await?;
}
let Some(obj) = value.as_object_mut() else {
return Ok(());
};
+13
View File
@@ -843,6 +843,19 @@ impl InputTransform {
pub fn new_javascript_expr(expr: &str) -> InputTransform {
InputTransform::Javascript { expr: expr.to_owned() }
}
/// Whether this transform fills its input. On an agent tool, an input it does not fill is
/// left to the model, which sees only those.
pub fn is_completed(&self) -> bool {
match self {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
}
impl TryFrom<UntaggedInputTransform> for InputTransform {
+3 -3
View File
@@ -1,8 +1,8 @@
use crate::ai::stream_event_processor::StreamEventProcessor;
use crate::ai::utils::{
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
is_completed_input_transform, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success, FlowContext,
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
FlowContext,
};
use crate::common::{get_root_job_id, OccupancyMetrics};
use crate::result_processor::handle_non_flow_job_error;
@@ -444,7 +444,7 @@ async fn enqueue_windmill_tool(
// Evaluate each input transform and merge with AI-provided args
for (key, transform) in input_transforms.iter() {
// We skip static empty / null values, those are the one the AI will fill in
if !is_completed_input_transform(transform) {
if !transform.is_completed() {
continue;
}
let result = evaluate_input_transform::<Box<RawValue>>(
+7 -13
View File
@@ -62,17 +62,6 @@ pub async fn parse_raw_script_schema(
Ok(to_raw_value(&schema))
}
pub fn is_completed_input_transform(transform: &InputTransform) -> bool {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
}
}
/// Filters out properties from a JSON schema that have completed input transforms.
/// This allows AI agents to only see and fill parameters that don't have user-configured values.
pub fn filter_schema_by_input_transforms(
@@ -88,7 +77,7 @@ pub fn filter_schema_by_input_transforms(
let keys_to_remove: HashSet<String> = input_transforms
.iter()
.filter_map(|(key, transform)| {
let is_completed = is_completed_input_transform(transform);
let is_completed = transform.is_completed();
if is_completed {
Some(key.clone())
} else {
@@ -779,7 +768,12 @@ mod tests {
.unwrap();
let preset = ["a", "c", "f"]
.iter()
.map(|k| (k.to_string(), InputTransform::Static { value: to_raw_value(&1) }))
.map(|k| {
(
k.to_string(),
InputTransform::Static { value: to_raw_value(&1) },
)
})
.collect();
let filtered = filter_schema_by_input_transforms(schema, &preset).unwrap();
+10 -16
View File
@@ -467,14 +467,11 @@ async fn find_ai_agent_tool_module_in_parent_agent(
.trim_start_matches("$res:")
.trim_start_matches("res://");
// Definitions only: resolving their defaults here would hit the same inaccessible resources.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
let resource_value = client.get_agent_value(agent_path).await.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
match resource_value {
serde_json::Value::Object(mut map) => match map.remove("tools") {
Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| {
@@ -819,14 +816,11 @@ pub async fn handle_ai_agent_job(
// Read raw and interpolate only the brain below. Interpolating the whole resource would also
// resolve each tool's default `$res:`/`$var:`, which a host flow may be overriding and which
// may be unreadable to whoever runs this flow — an unused tool could then fail the agent.
let resource_value = client
.get_resource_value::<serde_json::Value>(agent_path)
.await
.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
let resource_value = client.get_agent_value(agent_path).await.map_err(|e| {
Error::internal_err(format!(
"failed to load ai_agent resource {agent_path}: {e}"
))
})?;
let mut config = match resource_value {
serde_json::Value::Object(map) => map,
_ => {
+31 -6
View File
@@ -1,8 +1,8 @@
# Operator builder rights
Two workspace settings, `operator_settings.builder_flows` and `operator_settings.builder_apps`,
that let every operator of that workspace compose flows and full-code apps out of runnables that
already exist. Neither lets them author backend code or run arbitrary code on a worker, the
that let every operator of that workspace compose flows (and AI agents, under the flow right) and
full-code apps out of runnables that already exist. Neither lets them author backend code or run arbitrary code on a worker, the
boundary the operator role draws. The app right does let them write the app's frontend code,
which runs in the viewer's browser and is why that right rests on sandbox isolation.
@@ -32,13 +32,24 @@ anything that carries code. Three of its rules exist because the obvious walk mi
past it.
- **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a
tool can be a raw script.
- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent`
resource at run time, and operators may write resources, so the tool list is outside this check
and can be swapped for a raw script after the flow is approved.
It also returns what a value-only walk cannot authorize, for the caller to check against its own
permissions:
- **every agent a step links**. A linked agent resolves its tools from its `ai_agent` resource at
run time, outside this walk, so the link is allowed only to an agent the builder can read. That
is safe because of the agent rule below: an agent's tools are either a developer's code or
passed this same check when an operator saved them. Two things keep it safe:
- The step's `tool_inputs` replace a tool's inputs at run time, including the ones the agent's
author set. A builder's step may bind only the inputs the agent leaves open
(`check_bound_agent_inputs_are_open`, on `InputTransform::is_completed`), and the flow editor
lets a builder edit only those, showing the rest read-only. What it binds there, a `$var:` or
a JavaScript expression included, is what its own steps may already pass a deployed runnable; an input the author set
stays theirs. The check runs on the flow's write, so a tool or input the agent gains later is
open to a binding written before it, like the retyped path below.
- The worker refuses a linked path whose resource is no longer an `ai_agent`
(`get_agent_value`). Only writes of an agent are checked, so a path retyped, or recreated as
another type, after the link was approved would otherwise run whatever `tools` it now holds.
- **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group;
- **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB
handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which
@@ -61,6 +72,20 @@ Call it on every write **and** every preview: `run_preview_flow_job` and
it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow`
too: a developer who loads a builder's draft in the editor runs its code as themselves.
## Agents
An agent is an `ai_agent` resource, and operators may write resources, so the `flows` capability
is also the right to build agents. Without it a restricted role writes no agent at all. Its tools run in the
agent's own runs, which take only inputs and run what is stored, and in every flow step linking
it. So `validate_operator_agent` holds an operator's agent to the composed-flow rules by checking
its tools as the tools of a one-step flow. It is called from `resolve_agent_on_behalf_of`, which
every write of an agent's value passes through: create, update, retype, update-value and version
restore. An operator's resource draft is checked the same way when it names `ai_agent` **or** an
agent is deployed at its path, whatever type the draft names: deploying a draft from the resource
editor keeps the row's type. A draft saved before an agent is created at its path is not
re-checked when someone else deploys it. A new route that writes an agent's value must go through
that function.
The same reasoning applies to a builder-authored app, with one extra step. `execute_component`
resolves the runnable it runs on the root handle, so `validate_operator_composed_app` checks every
referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a
+1 -1
View File
@@ -16,7 +16,7 @@ code is `windmill_common::roles`.
| Capability | Values | Enforced |
|---|---|---|
| `code` | bool | yes: scripts, previews, dependency jobs, dynamic selects, resolving jobs, data table migrations, eval datasets |
| `flows` | `none` / `compose` / `full` | yes |
| `flows` | `none` / `compose` / `full` | yes; also AI agents, whose tools are checked as composed steps |
| `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` |
| `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` |
| `visibility.*` | bool per page | no, UI only |
@@ -1656,7 +1656,7 @@ ${
' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.'
)} When you decline something, give the reason stated here and keep to it.${when(
buildsFlows,
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.'
'\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An AI agent step may link a saved agent they can read, which runs its tools as the agent defines them. Its `tool_inputs` may only fill tool inputs the agent leaves for the AI to fill: the server refuses a step that changes an input the agent sets.'
)}${when(
buildsApps,
`\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.`
@@ -1707,8 +1707,12 @@ ${findItemsRule}${deployRule}${when(canWriteDraft, `\n${instructionsRule}`)}${wh
${external ? 'More rules:' : `Rules:${draftsOnlyRule}\n${findItemsRule}`}
${groupMembershipRule}- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed.
- An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when(
canDraftResources,
// An agent draft is a resource draft that the server checks under the flow builder right.
canDraftResources && canWriteFlowDraft,
" You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there."
)}${when(
buildsFlows,
' Its tools, like flow steps, may only reference deployed scripts and flows by path: the server refuses inline code and hub scripts.'
)}${when(
!external,
`
@@ -25,7 +25,7 @@ export function agentMenuItems(agent: {
/** Never deployed: no resource yet to hold permissions or to deploy onward. */
draftOnly?: boolean
wsSpecific?: boolean
/** Hides what a role that cannot code has no page for, as on a flow's menu. */
/** Hides what a role that cannot code has no page or right for, as on a flow's menu. */
noCode?: boolean
/** Undefined until loaded, when nothing is offered for deploy. */
deployUiSettings: WorkspaceDeployUISettings | undefined
@@ -35,6 +35,7 @@ export function agentMenuItems(agent: {
onDelete: (event?: MouseEvent) => void
}): Item[] {
const deployable =
!agent.noCode &&
!agent.wsSpecific &&
!agent.draftOnly &&
isDeployable('resource', agent.path, agent.deployUiSettings)
@@ -35,6 +35,7 @@
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { emptyString } from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
const operatingWorkspace = useOperatingWorkspace()
@@ -134,9 +135,17 @@
let draftOnly = $derived(draft?.noDeployed ?? false)
// Evals run against the deployed agent, and a draft-only one has none: the backend's
// `require_agent` would reject every run. Evaluating is authoring, so it is for those who can
// edit the agent. Unknown until the load answers, and warming the pane loads it.
// edit the agent, except a role without `code`: eval datasets are refused to it
// (`check_role_can_code`) whatever its flows right. Unknown until the load answers, and warming
// the pane loads it.
const editRights = useEditRights(() => ws)
let canEvaluate = $derived(
draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused
draft != undefined &&
!draft.loading &&
!draftOnly &&
!readOnly &&
!refused &&
editRights.capabilities.code
)
// The draft's path, so a rename shows where it is typed — including a never-deployed agent,
// stored at a minted `draft_<uuid>` path and named by the path its first deploy will create,
@@ -11,6 +11,7 @@
import AgentSlackSettings from './AgentSlackSettings.svelte'
import { WorkspaceService } from '$lib/gen'
import { userStore } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte'
/** An agent's own settings, laid out as the top of the resource editor: what it is saved as
@@ -27,6 +28,7 @@
}
let { draft, path, workspace, error = $bindable(), onRunAsChange = undefined }: Props = $props()
const acting = useActingUser(() => workspace)
let readOnly = $derived(!draft.canWrite)
// As the deploy resolves it: the deployer, unless one who may keep another picked them.
@@ -130,8 +132,11 @@
{/if}
</div>
</Section>
<Section label="Slack">
<AgentSlackSettings {draft} {path} {workspace} {nextRunAs} />
</Section>
<!-- Operators cannot share agents in Slack, builder rights or not (slack_agents.rs). -->
{#if acting.current?.operator === false}
<Section label="Slack">
<AgentSlackSettings {draft} {path} {workspace} {nextRunAs} />
</Section>
{/if}
</div>
{/if}
@@ -4,6 +4,7 @@
import type { InputTransform, ScriptLang } from '$lib/gen'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import InputTransformsViewer from '$lib/components/InputTransformsViewer.svelte'
import { Button } from '$lib/components/common'
import { ChevronDown, ChevronRight, Wrench } from 'lucide-svelte'
import type { PickableProperties } from '../previousResults'
@@ -16,6 +17,7 @@
} from '../agentToolUtils'
import { flowLocalAgentSchema, toolInputOverrides } from '../agentResourceUtils'
import { loadSchemaFromModule } from '../flowInfers'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
let {
tools,
@@ -35,6 +37,42 @@
} = $props()
let flowTools = $derived(tools.filter(isFlowModuleTool))
const roleCaps = useRoleCapabilities()
// The server checks bindings only below `full` (`check_operator_can_build`).
const composeOnlyFlows = $derived($roleCaps.flows === 'compose')
/** Mirrors the backend's `InputTransform::is_completed`: an input the agent fills is hidden from
* the model, and the server refuses a builder's binding of it. */
function isSetByAgent(transform: InputTransform | undefined): boolean {
if (transform?.type === 'static') {
return transform.value !== undefined && transform.value !== null
}
return transform?.type === 'javascript' && !!transform.expr?.trim()
}
/** A builder binds only the inputs the agent leaves to the model. */
function shownSchema(tool: FlowModuleTool): any {
const schema = schemas[tool.id]
if (!composeOnlyFlows || !schema?.properties) return schema
const base = baseInputs(tool)
const keep = Object.keys(schema.properties).filter((k) => !isSetByAgent(base[k]))
return {
...schema,
properties: Object.fromEntries(keep.map((k) => [k, schema.properties[k]])),
required: (schema.required ?? []).filter((k: string) => keep.includes(k))
}
}
/** The inputs `shownSchema` hides, shown read-only so the tool code reads in context. */
function agentSetInputs(tool: FlowModuleTool): Record<string, InputTransform> {
if (!composeOnlyFlows) return {}
const base = baseInputs(tool)
return Object.fromEntries(
Object.keys(schemas[tool.id]?.properties ?? {})
.filter((k) => isSetByAgent(base[k]))
.map((k) => [k, base[k]])
)
}
function baseInputs(tool: FlowModuleTool): Record<string, InputTransform> {
return ((tool.value as { input_transforms?: Record<string, InputTransform> })
@@ -182,7 +220,7 @@
{:else if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined}
<InputTransformSchemaForm
class="px-3 pt-1"
schema={schemas[tool.id]}
schema={shownSchema(tool)}
{pickableProperties}
{extraLib}
{workspace}
@@ -197,6 +235,13 @@
}
}
/>
{@const setInputs = agentSetInputs(tool)}
{#if Object.keys(setInputs).length > 0}
<div class="flex flex-col gap-1 px-3 pb-2">
<span class="text-xs font-semibold text-emphasis">Static inputs</span>
<InputTransformsViewer inputTransforms={setInputs} />
</div>
{/if}
{:else}
<div class="px-3 py-2 text-2xs text-tertiary">Loading inputs...</div>
{/if}
@@ -84,19 +84,20 @@
let savedAgentsLoading = $state(false)
let savedAgentsWs: string | undefined = undefined
async function loadSavedAgents() {
// The backend refuses a linked agent in a builder's flow.
if (!ws || savedAgentsWs === ws || composeOnlyFlows) {
const requested = ws
if (!requested || savedAgentsWs === requested) {
return
}
savedAgentsLoading = true
try {
const rs = await ResourceService.listResource({
workspace: ws,
workspace: requested,
resourceType: 'ai_agent',
perPage: 1000
})
if (requested !== ws) return
savedAgents = rs.map((r) => ({ path: r.path, description: r.description }))
savedAgentsWs = ws
savedAgentsWs = requested
} catch {
savedAgents = []
} finally {
@@ -386,9 +387,7 @@
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</Button>
{#if composeOnlyFlows}
<!-- Linked agents are refused to builders, see `loadSavedAgents`. -->
{:else if savedAgentsLoading}
{#if savedAgentsLoading}
<div class="flex items-center gap-2 p-2 text-xs text-tertiary">
<Loader2 size={13} class="animate-spin" /> Loading saved agents
</div>
@@ -93,8 +93,9 @@
},
{
key: 'flows',
label: 'Flows',
description: 'Compose assembles deployed scripts and flows without code of their own.'
label: 'Flows and AI agents',
description:
'Compose builds flows and AI agents out of deployed scripts and flows, without code of their own.'
},
{
key: 'apps',
+4 -1
View File
@@ -28,9 +28,12 @@ const role = (overrides: Partial<RoleCapabilities>): RoleCapabilities => ({
})
describe('edit rights', () => {
it('lets a compose-only flow role author flows and nothing else', () => {
it('lets a compose-only flow role author flows and agents and nothing else', () => {
const builder = role({ flows: 'compose' })
expect(roleCanAuthor('flow', builder)).toBe(true)
expect(roleCanAuthor('agent', builder)).toBe(true)
expect(roleCanDraft('agent', builder)).toBe(true)
expect(roleCanAuthor('agent', role({ apps: 'compose' }))).toBe(false)
expect(roleCanAuthor('script', builder)).toBe(false)
expect(roleCanAuthor('raw_app', builder)).toBe(false)
expect(roleCanDraft('raw_app', builder)).toBe(false)
+4 -2
View File
@@ -30,8 +30,9 @@ export type EditKind =
* Whether a role with `caps` lets its member author items of `kind` (create, edit, deploy or
* delete one): the role half of every check against the item handlers. Saving a draft is
* `roleCanDraft`'s rule, and which items they may write is `canEditItem`'s other half.
* - code (scripts, agents, pipelines): `code`;
* - flows and full-code apps: `compose` or above (`check_operator_can_build`);
* - code (scripts, pipelines): `code`;
* - flows, agents and full-code apps: `compose` or above (`check_operator_can_build`). An
* agent's tools are checked as a composed flow's steps are, under the flows right;
* - low-code apps: `full` apps only (apps.rs `require_full_apps`);
* - schedules and triggers: the matching manage right;
* - resources, variables and groups: always, their permissions alone decide.
@@ -39,6 +40,7 @@ export type EditKind =
export function roleCanAuthor(kind: EditKind, caps: RoleCapabilities): boolean {
switch (kind) {
case 'flow':
case 'agent':
return caps.flows !== 'none'
case 'raw_app':
return caps.apps !== 'none'
@@ -24,7 +24,8 @@
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { recentItems } from '$lib/components/home/recentItems.svelte'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { sendUserToast } from '$lib/toast'
/**
@@ -41,9 +42,10 @@
let testPane = $derived(host?.testPaneHandle())
let config = $derived(agent?.state?.args)
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
let canEdit = $derived(
config != undefined && (agent?.canWrite ?? false) && $roleCapabilities.code
)
const editRights = useEditRights()
let canAuthor = $derived(editRights.roleCanAuthor('agent'))
let canCode = $derived(editRights.capabilities.code)
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor)
// Recorded once the agent is in: a path with nothing deployed never loads one.
let loaded = $derived(config != undefined)
@@ -100,12 +102,13 @@
() => ws,
() => getDeployUiSettings()
)
// A role that cannot code gets no menu, as on a flow's or a script's page.
// A role that cannot build agents gets no menu, as on a flow's page.
let menuItems = $derived(
config != undefined && $roleCapabilities.code
config != undefined && canAuthor
? agentMenuItems({
path,
canWrite: canEdit,
noCode: !canCode,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
@@ -161,8 +164,9 @@
}
: undefined}
mainButtons={[
// Evaluating an agent builds datasets and runs against it: authoring, as editing is.
...(canEdit
// Evaluating an agent builds datasets and runs against it: authoring, as editing is, and
// eval datasets are refused to a role without `code` whatever its flows right.
...(canEdit && canCode
? [
{
label: 'Evals',
@@ -173,7 +177,11 @@
title: 'Run this agent against a dataset of cases',
onClick: () => evalsModal?.openModal()
}
},
}
]
: []),
...(canEdit
? [
{
label: 'Edit',
buttonProps: {