mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 16:03:33 +00:00
* Revert "docs: agent editor comments describe runs as the caller" This reverts commitd94919c9fe. * Revert "refactor: agents run as the caller, with a note on what a shared one needs" This reverts commit0748a2fb51. * fix: a run as an identity continues only the caller's own conversation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an agent run as its identity takes only attachments its caller may read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the resource editor keeps an agent's identity on an admin's save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: regenerate workspace tool schemas for preserve_on_behalf_of Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the rust client sets preserve_on_behalf_of on resource writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: agents run on behalf of their identity only when they opt in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an agent holds an identity only while it runs on behalf of it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: reduce the agent identity migration to clearing untrusted keys Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse decoded variable references in inputs to an agent run as its identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: user renames and offboarding re-point an agent's identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse model-written tool arguments that the tool's job would interpolate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: agent on-behalf-of migration after main's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the agent identity migration also clears trashed agents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.8 KiB
Rust
217 lines
7.8 KiB
Rust
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?)
|
|
}
|
|
|
|
async fn job_identity(db: &Pool<Postgres>, id: &str) -> anyhow::Result<(String, String)> {
|
|
Ok(
|
|
sqlx::query_as("SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid")
|
|
.bind(id)
|
|
.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. It exists only
|
|
/// while the agent opts into running as it.
|
|
#[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",
|
|
"run_on_behalf_of": true });
|
|
|
|
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"));
|
|
|
|
let mut off = value.clone();
|
|
off["run_on_behalf_of"] = json!(false);
|
|
let (status, body) = post(
|
|
&base_url,
|
|
"SECRET_TOKEN",
|
|
&format!("resources/update/{AGENT}"),
|
|
json!({ "value": off, "preserve_on_behalf_of": true }),
|
|
)
|
|
.await?;
|
|
assert!((200..300).contains(&status), "toggle off: {status} {body}");
|
|
assert_eq!(agent_identity(&db).await?, None);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Reading an agent is what allows running it. The run is the caller's unless the agent opts into
|
|
/// running as its identity: then `test-user-2`, a plain member, runs as `test-user-3`.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_agent_run_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}");
|
|
assert_eq!(
|
|
job_identity(&db, &body).await?,
|
|
("u/test-user-2".into(), "test-user-2".into())
|
|
);
|
|
|
|
sqlx::query(
|
|
"UPDATE resource SET value = value || '{\"run_on_behalf_of\": true}'
|
|
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, "an agent run on behalf of: {body}");
|
|
assert_eq!(
|
|
job_identity(&db, &body).await?,
|
|
("u/test-user-3".into(), "test-user-2".into())
|
|
);
|
|
|
|
// The worker would resolve it with the identity's token, however the JSON spells the `$`.
|
|
for raw in [
|
|
r#"{"user_message":"$var:u/test-user-3/secret"}"#,
|
|
r#"{"user_message":"\u0024var:u/test-user-3/secret"}"#,
|
|
] {
|
|
let resp = reqwest::Client::new()
|
|
.post(format!("{base_url}/w/test-workspace/{run}"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
|
.header("Content-Type", "application/json")
|
|
.body(raw)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status().as_u16(), 400, "a reference in {raw}");
|
|
}
|
|
|
|
// The run takes its conversation outside the caller's row policy, so a reader must not
|
|
// continue another user's conversation; an admin still may, as the policy allows.
|
|
let chat = format!("{run}?memory_id=7d3f6a52-1c4e-4b8a-9f2d-0e5c6b7a8d91");
|
|
let msg = json!({ "user_message": "hi" });
|
|
let (status, body) = post(&base_url, "SECRET_TOKEN_3", &chat, msg.clone()).await?;
|
|
assert_eq!(status, 201, "the owner starts it: {body}");
|
|
let (status, body) = post(&base_url, "SECRET_TOKEN_2", &chat, msg.clone()).await?;
|
|
assert_eq!(status, 400, "another reader: {body}");
|
|
assert!(body.contains("another user"), "{body}");
|
|
let (status, body) = post(&base_url, "SECRET_TOKEN", &chat, msg).await?;
|
|
assert_eq!(
|
|
status, 409,
|
|
"an admin passes ownership, held only by the running turn: {body}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The identity is a username inside the value, so renaming the user has to move it there too.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_agent_identity_follows_user_rename(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, created_by)
|
|
VALUES ('test-workspace', $1, $2, 'ai_agent', 'test-user-3')",
|
|
)
|
|
.bind(AGENT)
|
|
.bind(json!({ "run_on_behalf_of": true, "on_behalf_of": "u/test-user-2" }))
|
|
.execute(&db)
|
|
.await?;
|
|
let email: String =
|
|
sqlx::query_scalar("SELECT email FROM usr WHERE username = 'test-user-2' LIMIT 1")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(format!("{base_url}/users/rename/{email}"))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.json(&json!({ "new_username": "renamed" }))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status().as_u16();
|
|
assert_eq!(status, 200, "rename: {}", resp.text().await?);
|
|
assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/renamed"));
|
|
|
|
Ok(())
|
|
}
|