mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* feat(ai-sessions): share session artifacts with the workspace by link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * chore: cache the shared artifact queries for offline sqlx Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: replace a literal NUL byte in the shared artifact body limit comment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * test: pin that a shared artifact is confined to its workspace's path Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: sanitize shared artifact markdown and validate the artifact id on every route The shared page renders another member's markdown, so ArtifactBody now runs the repo's rehype-raw + rehype-sanitize chain with the chat's link renderer on top; only the session viewer opts into the chat code block (mermaid, apply button). The link renderer keeps a link's text when its href is empty or unsafe, and the scheme check moves to a tested helper. The status route checks artifact_id like share does, so a NUL is a 400 rather than a 500. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix(ai-sessions): say which way re-sharing moves an artifact link The popover offered "Update to v1" when a v2 link was open on a pinned v1, which reads as if v1 were newer. Each direction now has its own sentence and action: a newer version on screen updates the link, an older one shares that version instead, a rename updates the name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
5.8 KiB
Rust
185 lines
5.8 KiB
Rust
//! Shared AI session artifacts: one link per author and artifact, readable by any workspace
|
|
//! member until its retention window passes, and removable only by its author or an admin.
|
|
//!
|
|
//! Expiry is enforced on read as well as by the monitor's sweep, so a share past its window must
|
|
//! not be served in the gap before the sweep reaches it.
|
|
|
|
use serde_json::{json, Value};
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
const ADMIN: &str = "Bearer SECRET_TOKEN";
|
|
const MEMBER: &str = "Bearer SECRET_TOKEN_2";
|
|
|
|
async fn share(
|
|
client: &reqwest::Client,
|
|
base: &str,
|
|
token: &str,
|
|
content: &str,
|
|
) -> anyhow::Result<Value> {
|
|
let resp = client
|
|
.post(format!("{base}/share"))
|
|
.header("Authorization", token)
|
|
.json(&json!({
|
|
"artifact_id": "plan:session-1",
|
|
"name": "Plan",
|
|
"kind": "md",
|
|
"version": 1,
|
|
"content": content,
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
Ok(resp.json().await?)
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base", "ai_shared_artifacts"))]
|
|
async fn shared_artifact_is_served_to_members_until_it_expires(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base = format!(
|
|
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
|
server.addr.port()
|
|
);
|
|
let client = reqwest::Client::new();
|
|
|
|
let first = share(&client, &base, MEMBER, "draft").await?;
|
|
let second = share(&client, &base, MEMBER, "final").await?;
|
|
assert_eq!(first["id"], second["id"], "re-sharing minted a second link");
|
|
let id = second["id"].as_str().unwrap();
|
|
|
|
let resp = client
|
|
.get(format!("{base}/get/{id}"))
|
|
.header("Authorization", ADMIN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
let body: Value = resp.json().await?;
|
|
assert_eq!(body["content"], "final");
|
|
|
|
// The handlers read through the raw pool, so the workspace in the URL is the only thing
|
|
// scoping a share: a member of another workspace must not reach it by id through theirs.
|
|
let resp = client
|
|
.get(format!(
|
|
"http://localhost:{}/api/w/test-workspace-2/ai/shared_artifacts/get/{id}",
|
|
server.addr.port()
|
|
))
|
|
.header("Authorization", ADMIN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
404,
|
|
"a share was served through another workspace's path"
|
|
);
|
|
|
|
sqlx::query(
|
|
"UPDATE ai_shared_artifact SET shared_at = now() - ($1::bigint + 60) * interval '1 second'",
|
|
)
|
|
.bind(windmill_common::ai_shared_artifact_retention_secs())
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = client
|
|
.get(format!("{base}/get/{id}"))
|
|
.header("Authorization", ADMIN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 404, "an expired share was served");
|
|
|
|
let status: Value = client
|
|
.get(format!("{base}/status?artifact_id=plan:session-1"))
|
|
.header("Authorization", MEMBER)
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert!(
|
|
status.get("share").is_none(),
|
|
"an expired share was reported live: {status}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn only_the_author_or_an_admin_can_unshare(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base = format!(
|
|
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
|
server.addr.port()
|
|
);
|
|
let client = reqwest::Client::new();
|
|
|
|
let shared = share(&client, &base, ADMIN, "admin's plan").await?;
|
|
let id = shared["id"].as_str().unwrap();
|
|
|
|
let resp = client
|
|
.delete(format!("{base}/delete/{id}"))
|
|
.header("Authorization", MEMBER)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 404);
|
|
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM ai_shared_artifact")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(remaining, 1, "a member deleted someone else's share");
|
|
|
|
let member_share = share(&client, &base, MEMBER, "member's plan").await?;
|
|
let resp = client
|
|
.delete(format!(
|
|
"{base}/delete/{}",
|
|
member_share["id"].as_str().unwrap()
|
|
))
|
|
.header("Authorization", ADMIN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The id is compared against a `VARCHAR(255)` column on every route that takes one, and a
|
|
/// NUL in it would otherwise reach Postgres and come back as a 500.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_malformed_artifact_id_is_refused_on_every_route(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base = format!(
|
|
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
|
server.addr.port()
|
|
);
|
|
let client = reqwest::Client::new();
|
|
|
|
for bad_id in ["", "a\0b", &"x".repeat(256)] {
|
|
let resp = client
|
|
.get(format!("{base}/status"))
|
|
.query(&[("artifact_id", bad_id)])
|
|
.header("Authorization", MEMBER)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "status accepted {bad_id:?}");
|
|
|
|
let resp = client
|
|
.post(format!("{base}/share"))
|
|
.header("Authorization", MEMBER)
|
|
.json(&json!({
|
|
"artifact_id": bad_id,
|
|
"name": "Plan",
|
|
"kind": "md",
|
|
"version": 1,
|
|
"content": "x",
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "share accepted {bad_id:?}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|