Revert "refactor: agents run as the caller, with a note on what a shared one needs"

This reverts commit 0748a2fb51.
This commit is contained in:
hugocasa
2026-09-29 21:40:41 +02:00
parent f316f5cd90
commit 909a33402b
28 changed files with 834 additions and 147 deletions
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT resource_type, value AS \"value: sqlx::types::Json<Box<RawValue>>\"\n FROM resource WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "resource_type",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)\n SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by\n FROM resource\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "748b63f877498d6d279fdf23894611e11c7a05d69b4b7907f8909aec82fb1eb2"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)\n SELECT $2, path,\n CASE WHEN $3::text IS NOT NULL AND resource_type = 'ai_agent'\n AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of'\n THEN jsonb_set(value, '{on_behalf_of}', to_jsonb($3::text))\n ELSE value END,\n description, resource_type, extra_perms, edited_at, created_by\n FROM resource\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "bf763c3ae4951537033d18ce48d3eb83d5c19e32aef199bf161e8ba8d1adf176"
}
@@ -0,0 +1,4 @@
ALTER TABLE resource DISABLE TRIGGER record_resource_version_update_trigger;
UPDATE resource SET value = value - 'on_behalf_of'
WHERE resource_type = 'ai_agent' AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of';
ALTER TABLE resource ENABLE TRIGGER record_resource_version_update_trigger;
@@ -0,0 +1,43 @@
-- An AI agent's value carries the identity it runs as when run from its own page, as a flow's or
-- a script's `on_behalf_of` is: a principal (`u/<name>`, `g/<group>`), resolved by the server on
-- every write of the agent. An agent saved before this ran as whoever ran it; give it the
-- account that wrote its current version, the one a write now records.
-- Mirrors `users::username_to_permissioned_as`, as the sibling backfills do. `pg_temp` lives for
-- the whole session and migrations share one connection, so replace any earlier copy and drop
-- this one at the end.
CREATE OR REPLACE FUNCTION pg_temp.username_to_permissioned_as(name VARCHAR)
RETURNS VARCHAR AS $$
SELECT CASE
WHEN $1 LIKE '%@%' AND $1 LIKE '%/%' THEN 'u/' || $1
WHEN $1 LIKE '%@%' THEN $1
WHEN $1 LIKE 'group-%' THEN 'g/' || substr($1, 7)
ELSE 'u/' || $1
END;
$$ LANGUAGE SQL IMMUTABLE;
-- Filling in the identity is not an edit of the agent, so it records no version.
ALTER TABLE resource DISABLE TRIGGER record_resource_version_update_trigger;
-- A principal wider than `v2_job.permissioned_as` could not be enqueued, so it is left unset, as
-- the sibling backfills leave it (20260801043001, 20260911085221).
UPDATE resource r
SET value = jsonb_set(r.value, '{on_behalf_of}',
to_jsonb(pg_temp.username_to_permissioned_as(v.created_by)))
FROM (
SELECT DISTINCT ON (workspace_id, path) workspace_id, path, created_by
FROM resource_version
ORDER BY workspace_id, path, id DESC
) v
WHERE r.workspace_id = v.workspace_id
AND r.path = v.path
AND r.resource_type = 'ai_agent'
AND jsonb_typeof(r.value) = 'object'
AND NOT (r.value ? 'on_behalf_of')
AND v.created_by IS NOT NULL
AND v.created_by <> ''
AND length(pg_temp.username_to_permissioned_as(v.created_by)) <= 55;
ALTER TABLE resource ENABLE TRIGGER record_resource_version_update_trigger;
DROP FUNCTION pg_temp.username_to_permissioned_as(VARCHAR);
@@ -0,0 +1,115 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const AGENT: &str = "u/test-user-3/agent";
async fn post(
base_url: &str,
token: &str,
route: &str,
body: serde_json::Value,
) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(format!("{base_url}/w/test-workspace/{route}"))
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
async fn agent_identity(db: &Pool<Postgres>) -> anyhow::Result<Option<String>> {
Ok(sqlx::query_scalar::<_, Option<String>>(
"SELECT value->>'on_behalf_of' FROM resource
WHERE workspace_id = 'test-workspace' AND path = $1",
)
.bind(AGENT)
.fetch_one(db)
.await?)
}
/// The identity an agent runs as is in its value, which anyone who can write the agent writes: it
/// is kept only for an admin asking to keep it, and every other write makes it the writer's own,
/// so a writer cannot change what runs while it keeps running as someone else.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_agent_write_resets_identity(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
let value = json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-2" });
let (status, body) = post(
&base_url,
"SECRET_TOKEN",
"resources/create",
json!({ "path": AGENT, "resource_type": "ai_agent", "value": value,
"preserve_on_behalf_of": true }),
)
.await?;
assert!((200..300).contains(&status), "create: {status} {body}");
assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/test-user-2"));
// The agent's owner, not an admin, asking to keep it.
let (status, body) = post(
&base_url,
"SECRET_TOKEN_3",
&format!("resources/update/{AGENT}"),
json!({ "value": value, "preserve_on_behalf_of": true }),
)
.await?;
assert!((200..300).contains(&status), "update: {status} {body}");
assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/test-user-3"));
Ok(())
}
/// Reading an agent is what allows running it, and the run is the agent's identity, not the
/// caller's: `test-user-2`, a plain member, runs an agent that runs as `test-user-3`.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_agent_run_is_its_identity_and_needs_read(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')",
)
.bind(AGENT)
.bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-3" }))
.execute(&db)
.await?;
let run = format!("jobs/run/agent/{AGENT}");
let (status, body) = post(&base_url, "SECRET_TOKEN_2", &run, json!({})).await?;
assert_eq!(status, 404, "an unreadable agent: {body}");
sqlx::query(
"UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}'
WHERE workspace_id = 'test-workspace' AND path = $1",
)
.bind(AGENT)
.execute(&db)
.await?;
let (status, body) = post(
&base_url,
"SECRET_TOKEN_2",
&run,
json!({ "user_message": "hi" }),
)
.await?;
assert_eq!(status, 201, "a readable agent: {body}");
let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>(
"SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid",
)
.bind(&body)
.fetch_one(&db)
.await?;
assert_eq!(permissioned_as, "u/test-user-3");
assert_eq!(created_by, "test-user-2");
Ok(())
}
@@ -1,60 +0,0 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const AGENT: &str = "u/test-user-3/agent";
async fn run(base_url: &str, token: &str) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(format!(
"{base_url}/w/test-workspace/jobs/run/agent/{AGENT}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "user_message": "hi" }))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
/// Reading an agent is what allows running it, and the run is the caller's own: `test-user-2`, a
/// plain member, runs `test-user-3`'s agent once it may read it, lending nobody's permissions.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_agent_run_needs_read_and_runs_as_caller(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')",
)
.bind(AGENT)
.bind(json!({ "system_prompt": "hi" }))
.execute(&db)
.await?;
let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?;
assert_eq!(status, 404, "an unreadable agent: {body}");
sqlx::query(
"UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}'
WHERE workspace_id = 'test-workspace' AND path = $1",
)
.bind(AGENT)
.execute(&db)
.await?;
let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?;
assert_eq!(status, 201, "a readable agent: {body}");
let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>(
"SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid",
)
.bind(&body)
.fetch_one(&db)
.await?;
assert_eq!(permissioned_as, "u/test-user-2");
assert_eq!(created_by, "test-user-2");
Ok(())
}
@@ -62,14 +62,31 @@ async fn fork_with_public_app(
/// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs
/// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may
/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a
/// plain member of the parent.
/// not preserve someone else's identity must not receive one by forking. The same goes for the
/// one an agent's value carries. `test-user-2` is a plain member of the parent.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_repoints_app_identity_for_unprivileged_creator(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/agent', $1, 'ai_agent', '{}', 'test-user')",
)
.bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user" }))
.execute(&db)
.await?;
let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?;
assert_eq!(
sqlx::query_scalar::<_, String>(
"SELECT value->>'on_behalf_of' FROM resource
WHERE workspace_id = 'wm-fork-app' AND path = 'u/test-user/agent'"
)
.fetch_one(&db)
.await?,
"u/test-user-2"
);
assert_eq!(policy["on_behalf_of"], json!("u/test-user-2"));
assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev"));
// `execution_mode` rides along untouched — see `clone_apps`.
@@ -312,8 +329,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool<Postgres>) -> any
Ok(())
}
/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows
/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays.
/// Apps, agents, schedules, triggers and their drafts cannot drop an identity the way scripts and
/// flows do, so one naming nobody in the fork goes to its creator while one that still resolves
/// stays.
/// Forked as an admin, whose app policies the clone otherwise keeps.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyhow::Result<()> {
@@ -367,6 +385,13 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyho
))
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/stranger', $1, 'ai_agent', '{}', 'test-user')",
)
.bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-2" }))
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled)
VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)",
@@ -424,6 +449,10 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyho
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
"u/test-user"
);
assert_eq!(
text("SELECT value->>'on_behalf_of' FROM resource WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?,
"u/test-user"
);
assert_eq!(
text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?,
"u/test-user test@windmill.dev"
@@ -6672,7 +6672,7 @@ async fn clone_workspace_data(
clone_resource_types(tx, source_workspace_id, target_workspace_id).await?;
// Clone resources
clone_resources(tx, source_workspace_id, target_workspace_id).await?;
clone_resources(tx, source_workspace_id, target_workspace_id, authed).await?;
// Clone variables (including external secret backend replication)
clone_variables(tx, db, source_workspace_id, target_workspace_id).await?;
@@ -7205,18 +7205,30 @@ async fn clone_resource_types(
Ok(())
}
/// An agent's value carries the identity it runs as, re-pointed at the fork's creator when they
/// may not preserve someone else's, as `clone_apps` does an app's policy. The rest is swept by
/// `repoint_unresolvable_cloned_identities` once the fork's membership is final.
async fn clone_resources(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
authed: &ApiAuthed,
) -> Result<()> {
let repoint = (!windmill_common::can_preserve_on_behalf_of(authed))
.then(|| username_to_permissioned_as(&authed.username));
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)
SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by
SELECT $2, path,
CASE WHEN $3::text IS NOT NULL AND resource_type = 'ai_agent'
AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of'
THEN jsonb_set(value, '{on_behalf_of}', to_jsonb($3::text))
ELSE value END,
description, resource_type, extra_perms, edited_at, created_by
FROM resource
WHERE workspace_id = $1",
source_workspace_id,
target_workspace_id,
repoint,
)
.execute(&mut **tx)
.await?;
@@ -7703,6 +7715,18 @@ async fn repoint_unresolvable_cloned_identities(
.await?;
}
sqlx::query(&format!(
"UPDATE resource SET value = jsonb_set(value, '{{on_behalf_of}}', to_jsonb($2::text))
WHERE workspace_id = $1 AND resource_type = 'ai_agent'
AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of'
AND NOT ({})",
principal_resolves_sql("(value->>'on_behalf_of')")
))
.bind(target_workspace_id)
.bind(&principal)
.execute(&mut **tx)
.await?;
// `email` is still written for workers that predate `permissioned_as`.
sqlx::query(&format!(
"UPDATE schedule SET permissioned_as = $2, email = $3
@@ -13052,17 +13076,18 @@ async fn compare_two_flows(
});
}
/// The policy minus the identity pair. Deploying cannot converge a difference there — the
/// target recomputes the identity from the deployer's own choice, which offers its current
/// value, the deployer, or a typed-in one, never the source's — so listing an app for it
/// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either.
fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value {
let mut policy = policy.clone();
if let Some(obj) = policy.as_object_mut() {
/// An app's policy or an agent's value minus the identity it carries. Deploying cannot converge
/// a difference there — the target recomputes the identity from the deployer's own choice, which
/// offers its current value, the deployer, or a typed-in one, never the source's — so listing an
/// item for it alone leaves an entry no deploy can clear. `script` and `flow` compare no identity
/// either.
fn without_identity(value: &serde_json::Value) -> serde_json::Value {
let mut value = value.clone();
if let Some(obj) = value.as_object_mut() {
obj.remove("on_behalf_of");
obj.remove("on_behalf_of_email");
}
policy
value
}
async fn compare_two_apps(
@@ -13105,7 +13130,7 @@ async fn compare_two_apps(
// Check metadata and content differences
if let (Some(source), Some(target)) = (&source_app, &target_app) {
if source.summary != target.summary
|| policy_without_identity(&source.policy) != policy_without_identity(&target.policy)
|| without_identity(&source.policy) != without_identity(&target.policy)
|| source.value != target.value
|| source.raw_app != target.raw_app
{
@@ -13183,7 +13208,14 @@ async fn compare_two_resources(
// Check metadata differences
if let (Some(source), Some(target)) = (&source_resource, &target_resource) {
if source.value != target.value
let agent = source.resource_type == "ai_agent" && target.resource_type == "ai_agent";
let values_differ = if agent {
source.value.as_ref().map(without_identity)
!= target.value.as_ref().map(without_identity)
} else {
source.value != target.value
};
if values_differ
|| source.description != target.description
|| source.resource_type != target.resource_type
{
+7 -2
View File
@@ -15960,8 +15960,7 @@ paths:
summary: run a saved AI agent
description: |
Runs the `ai_agent` resource at `path` as stored, for any caller who can read it
(operators included), as the caller, who needs access to the agent's AI resource and to
whatever its tools use. With `memory_id`, the run is a turn
(operators included), as the agent's `on_behalf_of`. With `memory_id`, the run is a turn
of the caller's conversation with the agent; without, a single run.
operationId: runAgent
tags:
@@ -31607,6 +31606,9 @@ components:
type: string
ws_specific:
type: boolean
preserve_on_behalf_of:
type: boolean
description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.
required:
- path
- value
@@ -31631,6 +31633,9 @@ components:
type: string
ws_specific:
type: boolean
preserve_on_behalf_of:
type: boolean
description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.
Resource:
type: object
+36 -9
View File
@@ -2,8 +2,8 @@
//!
//! The run is built here from the stored agent rather than sent by the client, which is what lets
//! anyone who can read the agent run it, operators included, the way a deployed flow runs by path:
//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, who
//! needs access to the agent's AI resource and to whatever its tools use.
//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the agent's
//! `on_behalf_of`, which the server resolves on every write of the agent.
use std::collections::HashMap;
@@ -59,11 +59,16 @@ pub(crate) async fn run_agent(
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let Some(sqlx::types::Json(value)) = not_found_if_none(value, "Agent", path)? else {
let Some(sqlx::types::Json(mut value)) = not_found_if_none(value, "Agent", path)? else {
return Err(Error::BadRequest(format!(
"Agent {path} has no configuration"
)));
};
// Who the agent runs as, not one of its inputs.
let agent_obo = value
.as_object_mut()
.and_then(|o| o.remove("on_behalf_of"))
.and_then(|v| v.as_str().map(str::to_string));
let config = config_to_draft(value)?;
// A chat turn is filed where the agent's chat lists its conversations, which a flow cannot
@@ -76,10 +81,31 @@ pub(crate) async fn run_agent(
};
let flow_value = agent_flow(&config, chat)?;
let push_authed = authed.clone().into();
let on_behalf_of =
windmill_common::on_behalf_of_from_permissioned_as(agent_obo.as_deref(), &w_id, &db)
.await?;
// As `run_flow` picks the identity of a deployed flow: the agent's own when it has one, the
// caller's otherwise, which lends nobody's permissions.
let (email, permissioned_as, push_authed, isolation) = if let Some(obo) = on_behalf_of.as_ref()
{
(
&obo.email,
obo.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
&authed.email,
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
)
};
let (uuid, mut tx) = push(
&db,
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
isolation,
&w_id,
JobPayload::RawFlow {
value: flow_value,
@@ -88,8 +114,8 @@ pub(crate) async fn run_agent(
},
PushArgs::from(&args),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
@@ -106,7 +132,7 @@ pub(crate) async fn run_agent(
None,
None,
None,
Some(&push_authed),
push_authed.as_ref(),
false,
None,
authed.trigger_or_fallback(None),
@@ -118,7 +144,8 @@ pub(crate) async fn run_agent(
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
if chat {
// A real conversation, not a test one: this is the agent as deployed, not a draft.
// The caller's conversation, whoever the agent runs as, and a real one: this is the
// agent as deployed, not a draft under test.
handle_chat_conversation_messages(
&mut tx,
&authed,
@@ -933,7 +933,14 @@ pub(crate) async fn tarball_workspace(
.fetch_all(&mut *tx)
.await?;
for resource in resources {
for mut resource in resources {
// An agent's identity belongs to this workspace, and whoever writes it elsewhere gets
// their own, as `to_string_without_metadata` leaves out a flow's.
if resource.resource_type == "ai_agent" {
if let Some(obj) = resource.value.as_mut().and_then(|v| v.as_object_mut()) {
obj.remove("on_behalf_of");
}
}
let resource_str =
&to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap();
archive
+160 -4
View File
@@ -234,6 +234,10 @@ pub struct CreateResource {
pub labels: Option<Vec<String>>,
#[serde(default)]
pub ws_specific: Option<bool>,
/// `ai_agent` only: keep the identity `value.on_behalf_of` names, for a caller allowed to
/// (see `resolve_agent_on_behalf_of`).
#[serde(default)]
pub preserve_on_behalf_of: Option<bool>,
}
#[derive(Deserialize)]
struct EditResource {
@@ -243,6 +247,7 @@ struct EditResource {
resource_type: Option<String>,
labels: Option<Vec<String>>,
ws_specific: Option<bool>,
preserve_on_behalf_of: Option<bool>,
}
#[derive(Deserialize)]
@@ -1213,7 +1218,20 @@ async fn create_resource(
check_path_conflict(&mut tx, &w_id, &resource.path).await?;
}
let res_value = resource.value.unwrap_or_default();
let mut res_value = resource.value.unwrap_or_default();
if resource.resource_type == "ai_agent" {
res_value = resolve_agent_raw_value(
&mut tx,
&authed,
&db,
&w_id,
&resource.path,
&res_value,
resource.preserve_on_behalf_of.unwrap_or(false),
true,
)
.await?;
}
let raw_json = sqlx::types::Json(res_value.as_ref());
if resource.path.starts_with("f/app_themes/") {
@@ -2054,9 +2072,6 @@ async fn update_resource(
if let Some(npath) = &ns.path {
sqlb.set_str("path", npath);
}
if let Some(nvalue) = &ns.value {
sqlb.set_str("value", nvalue.to_string());
}
if let Some(nrt) = &ns.resource_type {
sqlb.set_str("resource_type", nrt);
}
@@ -2164,6 +2179,48 @@ async fn update_resource(
}
}
let current = sqlx::query!(
"SELECT resource_type, value AS \"value: sqlx::types::Json<Box<RawValue>>\"
FROM resource WHERE workspace_id = $1 AND path = $2",
&w_id,
path
)
.fetch_optional(&mut *tx)
.await?;
let resource_type = ns
.resource_type
.clone()
.or_else(|| current.as_ref().map(|c| c.resource_type.clone()));
// Retyping a resource into an agent makes its value an agent's, identity included, so it is
// resolved as a written one is.
let retyped = ns.resource_type.as_deref() == Some("ai_agent")
&& current
.as_ref()
.is_some_and(|c| c.resource_type != "ai_agent");
let nvalue = match &ns.value {
Some(v) => Some(v.clone()),
None if retyped => current.and_then(|c| c.value).map(|v| v.0),
None => None,
};
if let Some(nvalue) = nvalue {
let nvalue = if resource_type.as_deref() == Some("ai_agent") {
resolve_agent_raw_value(
&mut tx,
&authed,
&db,
&w_id,
ns.path.as_deref().unwrap_or(path),
&nvalue,
ns.preserve_on_behalf_of.unwrap_or(false),
false,
)
.await?
} else {
nvalue
};
sqlb.set_str("value", nvalue.to_string());
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let npath_o: Option<String> = sqlx::query_scalar(&sql)
.fetch_optional(&mut *tx)
@@ -2333,6 +2390,91 @@ async fn update_resource_value(
Ok(format!("value of resource {} updated", path))
}
/// Rewrites the identity an `ai_agent` runs as (`value.on_behalf_of`), resolved on every write of
/// it as a flow's `on_behalf_of` is on every deploy: 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.
async fn resolve_agent_on_behalf_of(
tx: &mut Transaction<'_, Postgres>,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
path: &str,
value: &mut serde_json::Value,
preserve: bool,
created: bool,
) -> Result<()> {
let Some(obj) = value.as_object_mut() else {
return Ok(());
};
let mut requested = obj
.remove("on_behalf_of")
.and_then(|v| v.as_str().map(str::to_string));
let mut preserve = preserve;
let explicit =
requested.is_some() && preserve && windmill_common::can_preserve_on_behalf_of(authed);
if created && !explicit && windmill_common::can_preserve_on_behalf_of(authed) {
if let Some((_, folder_default)) =
windmill_common::folders::resolve_folder_default_on_behalf_of(db, w_id, path).await?
{
requested = Some(folder_default);
preserve = true;
}
}
let own = windmill_common::users::username_to_permissioned_as(&authed.username);
// Always an identity: nothing requested is the writer's own.
let resolved = windmill_common::resolve_on_behalf_of(
None,
Some(requested.as_deref().unwrap_or(&own)),
preserve,
authed,
w_id,
db,
)
.await?;
if let Some(resolved) = resolved.as_ref() {
obj.insert("on_behalf_of".to_string(), resolved.clone().into());
}
if let Some(kept) = windmill_common::check_on_behalf_of_preservation(
resolved.as_deref(),
preserve,
authed,
&own,
) {
audit_log(
&mut **tx,
authed,
"resources.on_behalf_of",
if created {
ActionKind::Create
} else {
ActionKind::Update
},
w_id,
Some(path),
Some([("on_behalf_of", kept.as_str())].into()),
)
.await?;
}
Ok(())
}
/// `resolve_agent_on_behalf_of` on a value as the request carried it.
async fn resolve_agent_raw_value(
tx: &mut Transaction<'_, Postgres>,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
path: &str,
value: &RawValue,
preserve: bool,
created: bool,
) -> Result<Box<RawValue>> {
let mut parsed: serde_json::Value = serde_json::from_str(value.get())?;
resolve_agent_on_behalf_of(tx, authed, db, w_id, path, &mut parsed, preserve, created).await?;
Ok(serde_json::value::to_raw_value(&parsed)?)
}
/// Write a resource's value and run everything that has to follow it: a version row, the audit
/// entry, deployment metadata, the webhook, and dependent CI tests. Shared with version restore
/// so a restored value is indistinguishable downstream from any other edit.
@@ -2361,6 +2503,20 @@ async fn set_resource_value(
let mut tx = user_db.clone().begin(authed).await?;
let mut value = value;
if let Some(v) = value.as_mut() {
let resource_type = sqlx::query_scalar!(
"SELECT resource_type FROM resource WHERE workspace_id = $1 AND path = $2",
w_id,
path
)
.fetch_optional(&mut *tx)
.await?;
if resource_type.as_deref() == Some("ai_agent") {
resolve_agent_on_behalf_of(&mut tx, authed, db, w_id, path, v, false, false).await?;
}
}
// `RETURNING resource_type` rather than a second lookup: the advisory below has to know the
// type to leave `state` and `cache` alone, and this statement already runs.
let updated = sqlx::query_scalar!(
+66 -2
View File
@@ -20,6 +20,11 @@ import { Resource } from "../../../gen/types.gen.ts";
import { readInlinePathSync, readTextFile } from "../../utils/utils.ts";
import { isWorkspaceSpecificFile } from "../../core/specific_items.ts";
import { getCurrentGitBranch } from "../../utils/git.ts";
import {
buildPermissionedAsContext,
type PermissionedAsContext,
} from "../../core/permissioned_as.ts";
import { readEffectiveSyncBehavior } from "../../core/conf.ts";
export interface ResourceFile {
value: any;
@@ -83,6 +88,7 @@ export async function pushResource(
// only round-trip the canonical layout); the standalone `resource push`
// command pushes a single explicit file, where any pointer is fine.
enforceCanonicalFileset?: boolean,
permissionedAsContext?: PermissionedAsContext,
): Promise<void> {
remotePath = removeType(remotePath, "resource");
try {
@@ -93,6 +99,11 @@ export async function pushResource(
} catch {
// flow doesn't exist
}
const claimed = agentOnBehalfOf(
resource,
localResource,
permissionedAsContext
);
// Helper function to resolve inline content
const resolveInlineContent = async () => {
@@ -133,11 +144,23 @@ export async function pushResource(
// Only resolve inline content if we're actually updating
await resolveInlineContent();
if (claimed) {
log.info(`Preserving ${claimed} as permissioned_as for agent ${remotePath}`);
}
await wmill.updateResource({
workspace: workspace,
path: remotePath.replaceAll(SEP, "/"),
requestBody: { ...localResource, ...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}) },
requestBody: {
...localResource,
...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}),
...(claimed
? {
value: { ...localResource.value, on_behalf_of: claimed },
preserve_on_behalf_of: true,
}
: {}),
},
});
} else {
// New resource - resolve inline content
@@ -163,6 +186,41 @@ export async function pushResource(
}
}
/**
* An agent runs as the identity in its value's `on_behalf_of`, which the backend
* sets to whoever writes the agent. Like an app's, it is never taken from the
* tracked file, which the pull leaves it out of: the deployed one is claimed
* back only by a pusher the backend lets keep it, an admin or a `wm_deployers`
* member. Strips the key from `localResource` (and from the deployed value, so
* an unchanged agent still compares equal) and returns the identity to claim.
*/
export function agentOnBehalfOf(
deployed: ResourceFile | Resource | undefined,
localResource: ResourceFile,
permissionedAsContext: PermissionedAsContext | undefined
): string | undefined {
// What the push writes is the tracked file's type: an agent retyped into
// another resource keeps its value exactly as the file states it.
if (localResource.resource_type !== "ai_agent") {
return undefined;
}
const local = localResource.value;
if (local && typeof local === "object") {
delete local.on_behalf_of;
}
const value = deployed?.resource_type === "ai_agent" ? deployed.value : undefined;
const onBehalfOf =
value && typeof value === "object" ? value.on_behalf_of : undefined;
if (deployed && onBehalfOf !== undefined) {
const { on_behalf_of: _, ...rest } = value;
deployed.value = rest;
}
if (!onBehalfOf || !permissionedAsContext?.userIsAdminOrDeployer) {
return undefined;
}
return onBehalfOf;
}
type PushOptions = GlobalOptions;
async function push(opts: PushOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
@@ -184,7 +242,13 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) {
remotePath,
undefined,
parseFromFile(filePath),
filePath // Pass the local file path for branch-specific inline content resolution
filePath, // Pass the local file path for branch-specific inline content resolution
undefined,
undefined,
await buildPermissionedAsContext(
workspace.workspaceId,
await readEffectiveSyncBehavior(opts, workspace)
)
);
log.info(colors.bold.underline.green(`Resource ${remotePath} pushed`));
}
+1
View File
@@ -5918,6 +5918,7 @@ export async function push(
resourceFilePath,
isFileResWsSpecific ? true : undefined,
true,
permissionedAsContext,
);
}
// Already-synced parents got the full content this run.
+9
View File
@@ -246,6 +246,15 @@ export async function preCheckPermissionedAs(
if (match) {
currentOwner = match[1];
}
} else if (typeStr === "resource") {
// An agent always runs as someone, and the file never says who.
if (/["']?resource_type["']?\s*:\s*["']?ai_agent\b/.test(beforeContent)) {
wouldChangeItems.push({
path: change.path,
currentOwner: "(agent owner)",
});
}
continue;
} else if (typeStr.endsWith("_trigger")) {
wouldChangeItems.push({
path: change.path,
+1 -1
View File
@@ -248,7 +248,7 @@ export async function pushObj(
} else if (typeEnding === "resource") {
if (!alreadySynced.includes(p)) {
alreadySynced.push(p);
await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true);
await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true, permissionedAsContext);
}
} else if (typeEnding === "resource-type") {
await pushResourceType(workspace, p, befObj, newObj);
+25
View File
@@ -105,3 +105,28 @@ test("deployItem: never sends the source workspace's on_behalf_of", async () =>
expect("on_behalf_of" in JSON.parse(JSON.stringify(identity))).toBe(false);
}
});
// An agent carries its principal inside its resource value, and is handed the chosen one as a
// principal. The source's must never be sent: without a choice the key is dropped, leaving the
// backend to make the agent the deployer's.
test("deployItem: an agent takes the chosen principal, never the source's", async () => {
const captured: any[] = [];
const provider = {
existsResource: async () => true,
getResource: async () => ({
path: "f/x/agent",
resource_type: "ai_agent",
description: "",
value: { system_prompt: "hi", on_behalf_of: "u/alice" },
}),
updateResource: async (p: any) => void captured.push(p.requestBody),
} as any;
await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", "u/bob");
await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", undefined);
expect(captured[0].value).toEqual({ system_prompt: "hi", on_behalf_of: "u/bob" });
expect(captured[0].preserve_on_behalf_of).toBe(true);
expect(captured[1].value).toEqual({ system_prompt: "hi" });
expect(captured[1].preserve_on_behalf_of).toBe(false);
});
@@ -0,0 +1,57 @@
/**
* An agent's run-as identity lives in its value, which the pull leaves out. A
* push must neither see that gap as a change nor let the file pick who the
* agent runs as: only the deployed identity is claimed back, and only by a
* pusher the backend lets keep it.
*/
import { expect, test } from "bun:test";
import { isSuperset } from "../src/types.ts";
import { agentOnBehalfOf } from "../src/commands/resource/resource.ts";
const ctx = (userIsAdminOrDeployer: boolean) => ({
userCache: new Map(),
userIsAdminOrDeployer,
userEmail: "me@x.dev",
});
function agents(localObo?: string) {
const deployed = {
resource_type: "ai_agent",
value: { system_prompt: "hi", on_behalf_of: "u/bob" },
} as any;
const local = {
resource_type: "ai_agent",
value: { system_prompt: "hi", ...(localObo ? { on_behalf_of: localObo } : {}) },
} as any;
return { deployed, local };
}
test("an unchanged agent still compares equal to the deployed one", () => {
const { deployed, local } = agents();
agentOnBehalfOf(deployed, local, ctx(true));
expect(isSuperset(local, deployed)).toBe(true);
});
test("an admin or deployer claims the deployed identity, never the file's", () => {
const { deployed, local } = agents("u/admin");
expect(agentOnBehalfOf(deployed, local, ctx(true))).toBe("u/bob");
expect(local.value.on_behalf_of).toBeUndefined();
});
test("anyone else claims nothing", () => {
const { deployed, local } = agents("u/bob");
expect(agentOnBehalfOf(deployed, local, ctx(false))).toBeUndefined();
expect(agentOnBehalfOf(agents().deployed, agents().local, undefined)).toBeUndefined();
expect(local.value.on_behalf_of).toBeUndefined();
});
test("other resources are left alone, an agent retyped into one included", () => {
for (const deployedType of ["json", "ai_agent"]) {
const deployed = { resource_type: deployedType, value: { on_behalf_of: "u/bob" } } as any;
const local = { resource_type: "json", value: { on_behalf_of: "u/x" } } as any;
expect(agentOnBehalfOf(deployed, local, ctx(true))).toBeUndefined();
expect(local.value.on_behalf_of).toBe("u/x");
expect(deployed.value.on_behalf_of).toBe("u/bob");
}
});
+27 -3
View File
@@ -630,14 +630,25 @@ export async function deployItem(
workspace: workspaceFrom,
path,
});
let value = resource.value ?? "";
// An agent's value carries the principal it runs as, as a trigger's row does: the source's
// names nobody in the target, so it is replaced by the chosen one or dropped, leaving the
// backend to make it the deployer's.
const agent = resource.resource_type === "ai_agent" && typeof value === "object";
if (agent) {
const { on_behalf_of: _, ...rest } = value;
value = onBehalfOf ? { ...rest, on_behalf_of: onBehalfOf } : rest;
}
const agentPreserve = agent ? { preserve_on_behalf_of: preserveOnBehalfOf } : {};
if (alreadyExists) {
await provider.updateResource({
workspace: workspaceTo,
path,
requestBody: {
path,
value: resource.value ?? "",
value,
description: resource.description ?? "",
...agentPreserve,
},
});
} else {
@@ -645,9 +656,10 @@ export async function deployItem(
workspace: workspaceTo,
requestBody: {
path,
value: resource.value ?? "",
value,
resource_type: resource.resource_type,
description: resource.description ?? "",
...agentPreserve,
},
});
}
@@ -902,6 +914,11 @@ export async function getItemValue(
return variable.value;
} else if (kind === "resource") {
const resource = await provider.getResource({ workspace, path });
// Each workspace resolves its own agent identity, which a deploy never copies.
if (resource.resource_type === "ai_agent" && resource.value) {
const { on_behalf_of: _, ...value } = resource.value;
return value;
}
return resource.value;
} else if (kind === "resource_type") {
const rt = await provider.getResourceType({ workspace, path });
@@ -987,7 +1004,8 @@ function stripTriggerOrScheduleRuntimeFields(row: unknown): unknown {
/**
* Fetch the on_behalf_of value for a deployable item.
* Returns an email for flows/scripts/apps, or undefined if not applicable.
* Returns an email for flows/scripts/apps, a principal (u/username, g/group) for
* triggers/schedules and agents, or undefined if not applicable.
*/
export async function getOnBehalfOf(
provider: DeployProvider,
@@ -1005,6 +1023,12 @@ export async function getOnBehalfOf(
} else if (kind === "app" || kind === "raw_app") {
const app = await provider.getAppByPath({ workspace, path });
return app.policy?.on_behalf_of_email;
} else if (kind === "resource") {
// Only an agent runs as anyone, and like a trigger it records the principal.
const resource = await provider.getResource({ workspace, path });
return resource.resource_type === "ai_agent"
? resource.value?.on_behalf_of
: undefined;
} else if (kind === "schedule") {
const schedule = await provider.getSchedule({ workspace, path });
return schedule.permissioned_as;
@@ -554,12 +554,13 @@
}
async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) {
// Runnables carry an email; triggers/schedules carry `permissioned_as`.
// `getOnBehalfOf` handles the dispatch — we just need to skip kinds that
// have no on_behalf_of concept (resource, variable, folder, resource_type).
// Runnables carry an email; triggers/schedules and agents carry `permissioned_as`.
// `getOnBehalfOf` handles the dispatch (a resource that is not an agent has none) — we
// just need to skip kinds that have no on_behalf_of concept (variable, folder, resource_type).
const itemsWithOnBehalfOf = diffs.filter(
(d) =>
['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind)
['flow', 'script', 'app', 'raw_app', 'resource'].includes(d.kind) ||
isTriggerOrScheduleKind(d.kind)
)
for (const diff of itemsWithOnBehalfOf) {
for (const workspace of [currentWorkspaceId, parentWorkspaceId]) {
@@ -607,17 +608,22 @@
})
)
/** Triggers, schedules and agents record who they run as by principal, the rest by address. */
function usesPrincipal(kind: Kind): boolean {
return kind === 'trigger' || isTriggerOrScheduleKind(kind) || kind === 'resource'
}
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for
* triggers/schedules and agents.
*/
function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[itemKey]
if (choice === 'target') return getTargetOnBehalfOf(itemKey)
if (choice === 'custom') {
const details = customOnBehalfOf[itemKey]
const wantsPermissionedAs = kind === 'trigger' || isTriggerOrScheduleKind(kind)
return wantsPermissionedAs ? details?.permissionedAs : details?.email
return usesPrincipal(kind) ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
@@ -629,7 +635,7 @@
* returning undefined clears the source item's value rather than keeping it.
*/
function getOnBehalfOfPermissionedAsForDeploy(itemKey: string, kind: Kind): string | undefined {
if (kind === 'trigger' || isTriggerOrScheduleKind(kind)) return undefined
if (usesPrincipal(kind)) return undefined
if (onBehalfOfChoice[itemKey] !== 'custom') return undefined
return customOnBehalfOf[itemKey]?.permissionedAs
}
@@ -108,16 +108,22 @@
return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath])
}
/** Triggers and agents record who they run as by principal, the rest by address. */
function usesPrincipal(kind: Kind): boolean {
return kind === 'trigger' || kind === 'resource'
}
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for
* triggers/schedules and agents.
*/
function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[statusPath]
if (choice === 'target') return targetOnBehalfOfInfo[statusPath]
if (choice === 'custom') {
const details = customOnBehalfOf[statusPath]
return kind === 'trigger' ? details?.permissionedAs : details?.email
return usesPrincipal(kind) ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
@@ -132,7 +138,7 @@
statusPath: string,
kind: Kind
): string | undefined {
if (kind === 'trigger' || onBehalfOfChoice[statusPath] !== 'custom') return undefined
if (usesPrincipal(kind) || onBehalfOfChoice[statusPath] !== 'custom') return undefined
return customOnBehalfOf[statusPath]?.permissionedAs
}
@@ -203,7 +209,7 @@
// Fetch on_behalf_of_email for flows, scripts, apps, and triggers from both workspaces
for (const dep of sortedSet.filter((d) =>
['flow', 'script', 'app', 'trigger'].includes(d.kind)
['flow', 'script', 'app', 'trigger', 'resource'].includes(d.kind)
)) {
const key = computeStatusPath(dep.kind, dep.path)
let source: string | undefined
@@ -19,7 +19,9 @@
kind === 'app' ||
kind === 'raw_app' ||
kind === 'trigger' ||
isTriggerOrScheduleKind(kind)
isTriggerOrScheduleKind(kind) ||
// Only an agent's value carries an identity, so no other resource has a source value.
kind === 'resource'
if (!supported) return false
return !!sourceValue
}
@@ -144,18 +144,29 @@ export function agentDraftDeployRefusal(
*/
type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?: true }
/** Who a deploy makes the agent run as. The server resolves it as it does a flow's: without
* `preserve`, or from someone not allowed to keep another identity, it is the deployer. */
export interface AgentRunAs {
permissionedAs: string | undefined
preserve: boolean
}
async function writeAgentResource(
workspace: string,
fromPath: string,
state: AgentResourceState,
noDeployed: boolean
noDeployed: boolean,
runAs?: AgentRunAs
): Promise<AgentWriteResult> {
const body = {
path: state.path,
value: state.args,
value: runAs?.permissionedAs
? { ...state.args, on_behalf_of: runAs.permissionedAs }
: state.args,
description: state.description,
labels: state.labels,
ws_specific: state.wsSpecific
ws_specific: state.wsSpecific,
preserve_on_behalf_of: runAs?.preserve || undefined
}
try {
if (noDeployed) {
@@ -221,9 +232,12 @@ export interface AgentDraftHandle {
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
readonly refusal: string | undefined
readonly sync: TriggerDraftSync
/** Who the deployed agent runs as when run from its page; undefined when nothing is deployed
* or it runs as whoever runs it. */
readonly onBehalfOf: string | undefined
/** Write the current state to the resource and drop the draft. Resolves to the path written,
* which differs from the one loaded when the draft renames the agent, or undefined on failure. */
deploy: () => Promise<string | undefined>
deploy: (runAs?: AgentRunAs) => Promise<string | undefined>
}
/**
@@ -237,6 +251,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
let loading = $state(true)
let noDeployed = $state(false)
let canWriteResource = $state(true)
let onBehalfOf = $state<string | undefined>(undefined)
/** Guards the load against a path that changed under a slow response. */
let loadedFor = $state<string | undefined>(undefined)
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
@@ -324,10 +339,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
return
}
noDeployed = Boolean((r as any).no_deployed)
// Who it runs as is the deploy's to decide, not a setting the form or a draft holds.
const { on_behalf_of, ...args } = (r.value ?? {}) as AIAgentConfig & {
on_behalf_of?: string
}
onBehalfOf = noDeployed ? undefined : on_behalf_of
const deployedState: AgentResourceState = {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as AIAgentConfig,
args,
// Only where nothing else answers for the type: with a deployed row both the
// create below and the review page's `deployDraft` read it from there, and
// carrying it would make every draft the generic resource editor writes — which
@@ -408,7 +428,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
})
})
async function deploy(): Promise<string | undefined> {
async function deploy(runAs?: AgentRunAs): Promise<string | undefined> {
const ws = opts.workspace()
const fromPath = opts.path()
const s = state
@@ -424,7 +444,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
// made during the request as saved, and the banner would clear on a value the server never
// received; against the snapshot it stays a draft, which is what it is.
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
const written = await writeAgentResource(ws, fromPath, submitted, noDeployed)
const written = await writeAgentResource(ws, fromPath, submitted, noDeployed, runAs)
if (!written.ok) {
// A path that is no longer an agent tears this editor down; anything else is a plain error
// the user can retry from the form as it stands.
@@ -440,6 +460,10 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
deployed = submitted
noDeployed = false
// What the server resolved, which a request to keep an identity does not decide alone.
void ResourceService.getResource({ workspace: ws, path: submitted.path })
.then((r) => (onBehalfOf = (r.value as { on_behalf_of?: string } | undefined)?.on_behalf_of))
.catch(() => {})
const renamed = submitted.path !== fromPath
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
// what it is given, and the apply-effect copies that back over the form: against an edit made
@@ -479,6 +503,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
get refusal() {
return refusal
},
get onBehalfOf() {
return onBehalfOf
},
sync,
deploy
}
@@ -51,7 +51,8 @@
keepsManagedMemory
} from '../agentFormFields'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
import { useAgentDraft, type AgentRunAs } from '../agentDraft.svelte'
import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte'
import Path from '$lib/components/Path.svelte'
import Label from '$lib/components/Label.svelte'
import { sendUserToast } from '$lib/toast'
@@ -444,13 +445,14 @@
let pathError = $state('')
/** Who the next deploy makes the agent run as, as the "Permissioned as" line picked it. */
let runAs = $state<AgentRunAs | undefined>(undefined)
export function deploy(): Promise<boolean> {
if (pathError) {
sendUserToast(`Cannot deploy the agent: ${pathError}`, true)
return Promise.resolve(false)
}
return draft.deploy().then(async (written) => {
return draft.deploy(runAs).then(async (written) => {
// The path the write landed on, which a rename moves off the one this editor opened.
if (written) await onSaved?.(written)
return written !== undefined
@@ -566,13 +568,17 @@
disabled={readOnly}
/>
</Label>
<!-- An agent runs as whoever runs it, so sharing one through a folder shares nothing
it uses. -->
{#if !readOnly && draft.state?.path?.startsWith('f/')}
<Alert type="info" size="xs" title="Shared through its folder" class="mt-2">
Anyone who runs this agent needs access to its AI resource, and to the resources and
workspace scripts its tools use.
</Alert>
<!-- Who the deployed agent runs as when someone runs it from its page, resolved on
deploy as a flow's on-behalf-of identity is. -->
{#if !readOnly}
<div class="pt-2">
<PermissionedAsLine
permissionedAs={draft.onBehalfOf}
path={draft.state?.path}
onPermissionedAsChange={(permissionedAs, preserve) =>
(runAs = { permissionedAs, preserve })}
/>
</div>
{/if}
</div>
{/if}
@@ -122,6 +122,25 @@ describe('deployDraft preserves on_behalf_of', () => {
})
)
})
it('agent: claims the deployed identity, which its draft never carries', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed', on_behalf_of: 'u/alice' },
draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } }
} as any)
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true })
expect(ResourceService.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
value: { system_prompt: 'drafted', on_behalf_of: 'u/alice' },
preserve_on_behalf_of: true
})
})
)
})
})
// The resource branch reads the item again when the deploy lands, and falls back to the deployed
+14 -2
View File
@@ -233,8 +233,14 @@ function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean)
if (kind === 'resource') {
// draft: { args, description, resource_type, labels, wsSpecific }
// deployed row: { value, description, resource_type, labels, ws_specific }
// An agent's identity is in its deployed value and never in a draft, which is not an edit.
let value = (isDraft ? raw.args : raw.value) ?? {}
if (raw.resource_type === 'ai_agent') {
const { on_behalf_of: _, ...rest } = value
value = rest
}
return {
value: (isDraft ? raw.args : raw.value) ?? {},
value,
description: raw.description ?? '',
resource_type: raw.resource_type ?? undefined,
labels: raw.labels ?? undefined,
@@ -639,12 +645,18 @@ export async function deployDraft(
}
})
} else {
// As for an app: an agent draft carries no identity, so the deployed one is kept (the
// backend resets it to the deploying user without the flag, gated by
// can_preserve_on_behalf_of).
const agentObo =
deployed.resource_type === 'ai_agent' ? deployed.value?.on_behalf_of : undefined
await ResourceService.updateResource({
workspace,
path,
requestBody: {
path: d.path ?? path,
value: d.args ?? {},
value: agentObo ? { ...(d.args ?? {}), on_behalf_of: agentObo } : (d.args ?? {}),
preserve_on_behalf_of: agentObo ? true : undefined,
description: d.description ?? '',
labels: d.labels,
ws_specific: d.wsSpecific
+44 -3
View File
@@ -196,6 +196,11 @@ export type DeployConflict = { hit: boolean }
* A group kept as the target identity travels as its synthetic `group-*@windmill.dev`
* address, which an admin-created account holding it would win on the backend: known and
* accepted, see `users::permissioned_as_from_email`.
*
* An agent carries its principal inside its resource value, and is handed the chosen one as a
* principal, as a trigger is. Every read here drops the source's, so a diff never shows it and
* the published `deployItem` copies none; the write then stamps the chosen one, or none, which
* leaves the backend to make it the deployer's.
*/
function makeProvider(
onBehalfOfPrincipal?: string,
@@ -224,6 +229,17 @@ function makeProvider(
}
}
: app
const agents = new Set<string>()
const withAgentIdentity = <T extends Record<string, any>>(requestBody: T, path: string): T =>
agents.has(path) && requestBody.value && typeof requestBody.value === 'object'
? {
...requestBody,
value: onBehalfOf
? { ...requestBody.value, on_behalf_of: onBehalfOf }
: requestBody.value,
preserve_on_behalf_of: onBehalfOf !== undefined
}
: requestBody
const refuseUpdate = (): never => {
if (conflict) conflict.hit = true
throw new Error('item already exists in the target workspace')
@@ -276,9 +292,23 @@ function makeProvider(
createVariable: (p) => VariableService.createVariable(p),
updateVariable: (p) => VariableService.updateVariable(p),
deleteVariable: (p) => VariableService.deleteVariable(p),
getResource: (p) => ResourceService.getResource(p),
createResource: (p) => ResourceService.createResource(p),
updateResource: (p) => ResourceService.updateResource(p),
getResource: async (p) => {
const resource = await ResourceService.getResource(p)
if (resource.resource_type !== 'ai_agent' || !resource.value) return resource
agents.add(p.path)
const { on_behalf_of: _, ...value } = resource.value as Record<string, any>
return { ...resource, value }
},
createResource: (p) =>
ResourceService.createResource({
...p,
requestBody: withAgentIdentity(p.requestBody, p.requestBody.path)
}),
updateResource: (p) =>
ResourceService.updateResource({
...p,
requestBody: withAgentIdentity(p.requestBody, p.path)
}),
deleteResource: (p) => ResourceService.deleteResource(p),
getResourceType: (p) => ResourceService.getResourceType(p),
createResourceType: (p) => ResourceService.createResourceType(p),
@@ -569,6 +599,17 @@ export async function getOnBehalfOf(
return undefined
}
}
if (kind === 'resource') {
// Only an agent runs as anyone, and like a trigger it records the principal.
try {
const resource = await ResourceService.getResource({ workspace, path })
return resource.resource_type === 'ai_agent'
? (resource.value as { on_behalf_of?: string } | undefined)?.on_behalf_of
: undefined
} catch {
return undefined
}
}
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
}