feat: deploy a raw app from its sources, bundling them on a worker (#10500)

* feat: deploy a raw app from its sources, bundling them on a worker

* refactor: bundle raw app sources with the wmill CLI instead of a second bundler

* fix: address review findings on the raw app source deploy

* fix: bound bundle decompression, drop the npm dependency on slim workers

* fix: stop minting jobs:run for the source deploy, share the decode budget

* feat: let an MCP token grant the scopes its selected tools require

* fix: carry a caller-held extra scope through the MCP proxy

* fix: confine the run scope to the proxied request instead of the token

* fix: mint the run scope only for a token that names the tool

* fix: require write access before compiling, and state the grant where it is granted

* fix: let the database decide write access instead of restating its policies

* fix: answer a write denial with 403, not 401
This commit is contained in:
Ruben Fiszel
2026-08-04 14:29:13 +00:00
committed by GitHub
parent 9f3d15583a
commit 81ba9611eb
18 changed files with 1080 additions and 20 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET path = path WHERE path = $1 AND workspace_id = $2 RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "80a3a4e8f35190352aa4e4c5716013ce4a6f7b3ba27c63c77c4c765bf9076624"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "files",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941"
}
@@ -398,7 +398,10 @@ def schema_to_rust_value(schema: Optional[Dict[str, Any]]) -> str:
"""Convert a schema dict to a Rust serde_json::json! expression."""
if schema is None:
return "None"
return f"Some(serde_json::json!({json.dumps(schema, indent=8)}))"
# ensure_ascii=False: json.dumps would escape a non-ASCII character in a
# description as \uXXXX, which Rust rejects — its string literals spell it
# \u{XXXX}. Emitting the character itself is valid in both.
return f"Some(serde_json::json!({json.dumps(schema, indent=8, ensure_ascii=False)}))"
def build_tool_description(operation: Dict[str, Any], method: str, path: str) -> str:
"""Build the MCP tool description from OpenAPI summary and description."""
@@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool<Postgres>) -> anyho
"expected the raw update of a low-code app to be refused"
);
// The source endpoint refuses the same mismatch up front — it must not queue
// a bundle job (which no worker would pick up here) to find that out.
let resp = authed(client().post(format!("{base}/update_raw_source/{low_code_path}")))
.json(&json!({ "value": { "files": { "/index.tsx": "export {}" } } }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(
resp.text().await?.contains("is a low-code app"),
"expected the source deploy of a low-code app to be refused"
);
// Metadata-only updates and same-kind deploys still go through.
let resp = authed(client().post(format!("{base}/update/{raw_path}")))
.json(&json!({ "summary": "Renamed" }))
+72 -1
View File
@@ -12299,7 +12299,7 @@ paths:
summary: update app
operationId: updateApp
x-mcp-tool: true
x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead."
x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."
x-mcp-tool-include-fields:
- path
- value
@@ -12354,6 +12354,77 @@ paths:
schema:
type: string
/w/{workspace}/apps/update_raw_source/{path}:
post:
# The summary is what the MCP token picker shows next to the tool, so it has
# to carry the consequence of granting it, not just what it does.
summary: update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so)
operationId: updateAppRawSource
x-mcp-tool: true
x-mcp-instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead."
x-mcp-tool-include-fields:
- path
- value
- summary
- policy
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
requestBody:
description: raw app sources to bundle and deploy
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
summary:
type: string
value:
type: object
description: "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged."
properties:
files:
type: object
additionalProperties:
type: string
runnables:
type: object
data:
type: object
required: [files]
policy:
$ref: "#/components/schemas/Policy"
deployment_message:
type: string
custom_path:
type: string
preserve_on_behalf_of:
type: boolean
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
labels:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
allow_kind_change:
type: boolean
description: "When true, this deploy may switch the app between low-code and raw. Without it, deploying a value to an app of the other kind is refused so an app is never converted by accident."
required: [value]
responses:
"200":
description: app updated
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/update_raw/{path}:
post:
summary: update app
+168 -15
View File
@@ -13,6 +13,7 @@ use crate::job_helpers_oss::{
spawn_storage_usage_recount_floored,
};
use crate::{
apps_raw_bundle,
auth::{get_end_user_email, AuthCache, OptTokened},
db::{ApiAuthed, DB},
jobs::RunJobQuery,
@@ -117,6 +118,11 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
"/update_raw/{*path}",
post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
)
.route(
"/update_raw_source/{*path}",
post(update_app_raw_source)
.layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
)
.route("/delete/{*path}", delete(delete_app))
.route("/create", post(create_app))
.route(
@@ -2618,6 +2624,167 @@ async fn update_app(
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
/// takes the bundle the caller already built (the editor and the CLI both
/// bundle before calling it); an API client has nothing to build with, so this
/// is the raw-app deploy an MCP agent or a script can actually reach.
async fn update_app_raw_source(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
));
}
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
// The sources are compiled by a job on a worker: dependency resolution and
// the build run there, on caller-supplied input. A token that can't run jobs
// must not gain that through an app write, so require both scopes.
check_scopes(&authed, || "jobs:run".to_string())?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let Some(value) = ns.value.as_ref() else {
return Err(Error::BadRequest(
"value with the app's `files` is required to deploy a raw app".to_string(),
));
};
let files: RawAppSourceFiles = serde_json::from_str(value.0.get())
.map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?;
// All before the compile, which costs a job on a worker: it must not run for
// a path with no app, for a caller who can only read one (RLS refuses the
// write, but not until their sources have been built), or for an app of the
// other kind, which update_app_internal refuses anyway.
let deployed_raw_app = deployed_app_kind(&user_db, &authed, &w_id, path)
.await?
.ok_or_else(|| Error::NotFound(format!("App {path} not found")))?;
if !can_write_app(&user_db, &authed, &w_id, path).await? {
return Err(Error::PermissionDenied(format!(
"You do not have permission to update app {path}"
)));
}
if !ns.allow_kind_change.unwrap_or(false) {
reject_kind_change(path, true, Some(deployed_raw_app))?;
}
let (js, css) =
apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files)
.await?;
let opath = path.to_string();
let db2 = db.clone();
let (mut tx, npath, v_id) =
update_app_internal(authed, db, user_db, &w_id, path, true, ns).await?;
store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(js), &mut tx).await?;
if !css.is_empty() {
store_raw_app_file(&w_id, &v_id, "css", bytes::Bytes::from(css), &mut tx).await?;
}
tx.commit().await?;
tally_app_rename(&db2, &w_id, &opath, &npath, v_id).await;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateApp {
workspace: w_id.clone(),
old_path: opath.clone(),
new_path: npath.clone(),
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// The part of a raw app's value the bundler needs.
#[derive(Deserialize)]
struct RawAppSourceFiles {
#[serde(default)]
files: HashMap<String, String>,
}
fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option<bool>) -> Result<()> {
if !deployed_raw_app.is_some_and(|deployed| deployed != raw_app) {
return Ok(());
}
// Name the folder suffix too: a sync push picks the endpoint from the repo
// layout, so its operator has no endpoint to swap, only a folder.
let (kind, endpoint, folder) = if raw_app {
("a low-code app", "/apps/update", ".app")
} else {
("a raw app", "/apps/update_raw", ".raw_app")
};
Err(Error::BadRequest(format!(
"App {path} is {kind}: deploying a value to it through the other kind's endpoint \
would convert it and strand its bundle. Deploy it through {endpoint} instead \
(from a synced repo, from a `{folder}` folder), or set allow_kind_change to \
convert it on purpose."
)))
}
/// Whether the caller may write the app — decided by the database rather than by
/// restating its policies here, which is how a hand-written check came to miss
/// that the app's RLS grants group members write on a `g/<group>/…` path. The
/// probe is the write itself, rolled back; `path = path` touches no column any
/// trigger watches.
async fn can_write_app(
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
path: &str,
) -> Result<bool> {
let mut tx = user_db.clone().begin(authed).await?;
let allowed = sqlx::query_scalar!(
"UPDATE app SET path = path WHERE path = $1 AND workspace_id = $2 RETURNING 1",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?
.is_some();
tx.rollback().await?;
Ok(allowed)
}
/// Whether the app deployed at `path` is raw, or None when there is none the
/// caller can see. Through `user_db`, so it can't tell a caller anything about
/// an app they aren't allowed to read.
async fn deployed_app_kind(
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
path: &str,
) -> Result<Option<bool>> {
let mut tx = user_db.clone().begin(authed).await?;
let deployed_raw_app = sqlx::query_scalar!(
"SELECT app_version.raw_app FROM app
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.path = $1 AND app.workspace_id = $2",
path,
w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Ok(deployed_raw_app)
}
async fn update_app_raw<'a>(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -2722,21 +2889,7 @@ async fn update_app_internal<'a>(
}
None => None,
};
if deployed_raw_app.is_some_and(|deployed| deployed != raw_app) {
// Name the folder suffix too: a sync push picks the endpoint from the
// repo layout, so its operator has no endpoint to swap, only a folder.
let (kind, endpoint, folder) = if raw_app {
("a low-code app", "/apps/update", ".app")
} else {
("a raw app", "/apps/update_raw", ".raw_app")
};
return Err(Error::BadRequest(format!(
"App {path} is {kind}: deploying a value to it through the other kind's endpoint \
would convert it and strand its bundle. Deploy it through {endpoint} instead \
(from a synced repo, from a `{folder}` folder), or set allow_kind_change to \
convert it on purpose."
)));
}
reject_kind_change(path, raw_app, deployed_raw_app)?;
}
let mut preserved_on_behalf_of: Option<String> = None;
+256
View File
@@ -0,0 +1,256 @@
//! Server-side bundling of a raw app's sources.
//!
//! A raw app is served as a compiled js/css bundle, so every deploy path has to
//! compile first: the editor bundles in a browser iframe, the CLI bundles on the
//! developer's machine. Neither is reachable from a plain API call, which is why
//! `/apps/update_raw` takes the bundle as multipart and why there was no way to
//! deploy a raw app from an API client (an MCP agent, most of all — its only
//! app-write tool was the low-code one, which converted the app instead).
//!
//! The compile runs as a normal bun job on a worker: no new job kind, no new
//! executor, and the build's logs, timeout, cancellation and attribution are the
//! ones every other job gets. The build itself is `wmill app bundle`, so this
//! adds no bundler of its own to keep in step with the CLI's and the editor's.
use std::collections::HashMap;
use std::io::Read;
use base64::Engine;
use serde::Deserialize;
use uuid::Uuid;
use windmill_common::{
db::UserDB,
error::{Error, Result},
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
worker::to_raw_value,
DB,
};
use windmill_queue::{push, PushArgs, PushIsolationLevel};
use crate::db::ApiAuthed;
/// The bundle job's script, as its own file so it stays readable TypeScript.
const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts");
/// Cap the job so a pathological `package.json` can't sit on a worker forever.
/// This bounds the *run*, not the wait: see `wait_for_bundle`.
const BUNDLE_TIMEOUT_SECS: i32 = 300;
#[derive(Deserialize)]
struct BundleResult {
js_gz: String,
css_gz: String,
}
/// This server's release, without the git describe suffix an off-tag build
/// carries — the CLI is published per release, so that is the version to ask npm
/// for, and the one an installed CLI must report to be used instead.
fn release_version() -> String {
let v = &*windmill_common::utils::GIT_SEM_VERSION;
format!("{}.{}.{}", v.major, v.minor, v.patch)
}
/// The build command the job falls back to when the worker has no usable `wmill`
/// installed: the CLI for this server's release, fetched on the spot. A dev
/// server is off-tag and so asks for the last release; to build with an
/// unreleased CLI set `WM_RAW_APP_BUNDLER_CLI` to the whole command, e.g.
/// `bun run /path/to/cli/src/main.ts app bundle` — which also stops the job from
/// preferring an installed `wmill`.
fn bundler_cli_command() -> Vec<String> {
match std::env::var("WM_RAW_APP_BUNDLER_CLI") {
Ok(cmd) if !cmd.trim().is_empty() => {
cmd.split_whitespace().map(|s| s.to_string()).collect()
}
// `bun x`, not `bunx`: the images copy the `bun` binary alone, so the
// `bunx` entry point isn't on a worker's PATH.
_ => vec![
"bun".to_string(),
"x".to_string(),
"--bun".to_string(),
format!("windmill-cli@{}", release_version()),
"app".to_string(),
"bundle".to_string(),
],
}
}
/// Compile `files` into the js/css a deployed raw app serves. Returns the
/// build's own error when it fails, so the caller sees the compile error rather
/// than a generic failure.
///
/// This makes a worker run a build on caller-supplied sources, so it requires
/// `jobs:run` here rather than trusting each caller to have checked: a token
/// that can't run jobs must not gain that by writing an app.
pub(crate) async fn bundle_raw_app_sources(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
files: &HashMap<String, String>,
) -> Result<(String, String)> {
crate::utils::check_scopes(authed, || "jobs:run".to_string())?;
if files.is_empty() {
return Err(Error::BadRequest(
"app value has no `files` to bundle".to_string(),
));
}
// A queued bundle holds this request open until it runs, so refuse early
// rather than pile up connections waiting behind a backlog.
windmill_api_jobs::execution::check_queue_too_long(
db,
*windmill_api_jobs::execution::QUEUE_LIMIT_WAIT_RESULT,
)
.await?;
let shared_ui = shared_ui_files(user_db, authed, w_id).await?;
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
args.insert("files".to_string(), to_raw_value(files));
args.insert("shared_ui".to_string(), to_raw_value(&shared_ui));
let overridden = std::env::var("WM_RAW_APP_BUNDLER_CLI").is_ok_and(|c| !c.trim().is_empty());
args.insert(
"cli_command".to_string(),
to_raw_value(&bundler_cli_command()),
);
args.insert(
"prefer_installed_cli".to_string(),
to_raw_value(&!overridden),
);
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let (uuid, tx) = push(
db,
tx,
w_id,
JobPayload::Code(RawCode {
hash: None,
content: BUNDLER_TS.to_string(),
path: Some("bundle raw app".to_string()),
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
}),
PushArgs { args: &args, extra: None },
authed.display_username(),
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
Some(BUNDLE_TIMEOUT_SECS),
None,
None,
Some(&authed.clone().into()),
false,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
tx.commit().await?;
wait_for_bundle(db, w_id, uuid, authed).await
}
/// Waits for the bundle job. The wait itself is bounded by
/// `TIMEOUT_WAIT_RESULT`, not by `BUNDLE_TIMEOUT_SECS` — the job's timeout only
/// starts once a worker picks it up.
async fn wait_for_bundle(
db: &DB,
w_id: &str,
uuid: Uuid,
authed: &ApiAuthed,
) -> Result<(String, String)> {
let (result, success) = windmill_api_jobs::execution::run_wait_result_internal(
db,
uuid,
w_id,
None,
false,
&authed.username,
)
.await?;
if !success {
// The job's error is the compile error the caller needs to act on.
return Err(Error::BadRequest(format!(
"raw app bundling failed (job {uuid}): {}",
result.get()
)));
}
let bundle: BundleResult = serde_json::from_str(result.get()).map_err(|e| {
Error::internal_err(format!(
"unexpected raw app bundler result (job {uuid}): {e}"
))
})?;
// One budget across both, so the pair can't hold twice the limit in memory.
let limit = *crate::REQUEST_SIZE_LIMIT.read().await * 5;
let js = gunzip_b64(&bundle.js_gz, limit)?;
let css = gunzip_b64(&bundle.css_gz, limit - js.len())?;
Ok((js, css))
}
/// Bounded: what the job returns is compressed, so the result-size cap says
/// nothing about what it expands to, and the sources that produced it came from
/// the caller. The budget is shared by the js and css of one bundle, and matches
/// what `/apps/update_raw` accepts as a whole body.
fn gunzip_b64(b64: &str, limit: usize) -> Result<String> {
let compressed = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid base64: {e}")))?;
// Bytes, not a String: over the limit the read stops mid-stream, and
// read_to_string would report that as invalid utf-8 rather than as the size
// it is. One byte past, so a bundle that just fits is told from one cut short.
let mut out = Vec::new();
flate2::read::GzDecoder::new(&compressed[..])
.take(limit as u64 + 1)
.read_to_end(&mut out)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid gzip: {e}")))?;
if out.len() > limit {
// `limit` is what is left of the budget, not the whole of it, so say so
// rather than report a nearly-exhausted budget as the limit itself.
return Err(Error::BadRequest(format!(
"raw app bundle is too large: {limit} bytes left of the budget the js and css share"
)));
}
String::from_utf8(out)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid utf-8: {e}")))
}
async fn shared_ui_files(
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
) -> Result<HashMap<String, String>> {
let mut tx = user_db.clone().begin(authed).await?;
let files = sqlx::query_scalar!(
"SELECT files FROM workspace_shared_ui WHERE workspace_id = $1",
w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Ok(files
.and_then(|f| serde_json::from_value::<HashMap<String, String>>(f).ok())
.unwrap_or_default())
}
@@ -0,0 +1,121 @@
/**
* Bundles a raw app's sources into the js/css a deployed raw app serves. Runs as
* a bun job so the compile happens on a worker, not in the API process — see
* `apps_raw_bundle.rs`, which is the only thing that runs it.
*
* The build itself is `wmill app bundle`, the same one `wmill app push` runs, so
* an app deployed through the API is compiled exactly as the CLI and the editor
* compile it — entry point, virtual `wmill` module, `/ui/` shared-UI resolution
* and the Svelte/Vue plugins included. Reimplementing any of that here would be
* a third bundler to keep in step with the other two.
*/
export async function main(
files: Record<string, string>,
shared_ui: Record<string, string> | undefined,
cli_command: string[],
// Set unless the server was told to build with a specific command.
prefer_installed_cli: boolean | undefined
): Promise<{ js_gz: string; css_gz: string }> {
const fs = await import('node:fs/promises')
const path = await import('node:path')
const dir = path.join(process.cwd(), 'wm_raw_app')
await fs.rm(dir, { recursive: true, force: true })
// Where a key lands, `path.join` normalising `./` and `..` away. Everything
// that reasons about a file goes through this, so nothing disagrees with what
// was actually written.
const target = (rel: string) => {
const abs = path.join(dir, rel.replace(/^\/+/, ''))
if (!abs.startsWith(dir + path.sep)) {
throw new Error(`file path escapes the build directory: ${rel}`)
}
return abs
}
const write = async (rel: string, content: string) => {
const abs = target(rel)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, content)
}
for (const [p, content] of Object.entries(files ?? {})) {
await write(p, content)
}
// `ui/` next to the app is where `wmill app bundle` looks for the shared UI.
for (const [p, content] of Object.entries(shared_ui ?? {})) {
await write('ui/' + p.replace(/^\/+/, ''), content)
}
const manifest = path.join(dir, 'package.json')
const hasPackageJson = Object.keys(files ?? {}).some((p) => target(p) === manifest)
// Piped rather than inherited so the output can go in the error too, then
// echoed either way — the job's log is where someone looks to see what the
// build did.
const spawn = (argv: string[]) => {
const proc = Bun.spawnSync(argv, { cwd: dir, stdout: 'pipe', stderr: 'pipe' })
const output = proc.stdout.toString() + proc.stderr.toString()
console.log(output)
return { ok: proc.exitCode === 0, output }
}
const run = (argv: string[], what: string) => {
const { ok, output } = spawn(argv)
if (!ok) {
throw new Error(`${what} failed:\n${output}`)
}
return output
}
if (hasPackageJson) {
// Installed here rather than left to the CLI so it can be --ignore-scripts:
// the app's dependencies are compiled, never run, so a package's lifecycle
// script has no business executing on the worker. The CLI skips its own
// install once node_modules exists.
run(['bun', 'install', '--ignore-scripts'], 'bun install')
} else {
// The CLI installs when node_modules is missing, and it shells out to npm,
// which the slim images don't ship. An app with no manifest has nothing to
// install, so hand it the empty directory it would have produced.
await fs.mkdir(path.join(dir, 'node_modules'), { recursive: true })
}
const outDir = path.join(dir, 'dist')
// Prefer the CLI the image installed: no npm reachability needed at deploy
// time. It can predate `app bundle` (the images install it unpinned), and a
// CLI without the command exits with cliffy's usage text before building
// anything — so that specific failure, and only it, falls back to fetching
// the one for this server's release. `wmill --version` isn't used to decide:
// it reports npm's latest release as well as its own, and it reaches out to
// npm to do so, which is the cost this branch exists to avoid.
const installed = prefer_installed_cli ? Bun.which('wmill') : null
let buildOutput: string | undefined
if (installed) {
const attempt = spawn([installed, 'app', 'bundle', dir, '--out', outDir])
// Colours sit between the words cliffy prints, so match on the stripped text.
const plain = attempt.output.replace(/\x1b\[[0-9;]*m/g, '')
if (attempt.ok) {
buildOutput = attempt.output
} else if (!/Unknown command|Usage:\s+wmill app\b/.test(plain)) {
throw new Error(`bundle failed:\n${attempt.output}`)
}
}
if (buildOutput === undefined) {
buildOutput = run([...cli_command, dir, '--out', outDir], 'bundle')
}
const read = async (name: string) => {
try {
return await fs.readFile(path.join(outDir, name), 'utf8')
} catch {
return ''
}
}
const js = await read('bundle.js')
const css = await read('bundle.css')
if (js === '') {
throw new Error('bundle produced no javascript:\n' + buildOutput)
}
// Gzipped so a large app's bundle stays well inside MAX_RESULT_SIZE_MB, which
// a deployment can set far below the 500MB default.
const gz = (s: string) => Buffer.from(Bun.gzipSync(Buffer.from(s, 'utf8'))).toString('base64')
return { js_gz: gz(js), css_gz: gz(css) }
}
+1
View File
@@ -68,6 +68,7 @@ use windmill_common::error::AppError;
mod ai;
mod ai_skills;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
pub mod args;
mod audit;
@@ -1113,7 +1113,7 @@ Creates a new version of an existing script when called with the same path and t
EndpointTool {
name: Cow::Borrowed("updateApp"),
description: Cow::Borrowed("update app"),
instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead."),
instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."),
path: Cow::Borrowed("/w/{workspace}/apps/update/{path}"),
method: Cow::Borrowed("POST"),
path_params_schema: Some(serde_json::json!({
@@ -1152,6 +1152,123 @@ Creates a new version of an existing script when called with the same path and t
query_field_renames: None,
body_field_renames: Some(serde_json::json!({
"path__body": "path"
})),
},
EndpointTool {
name: Cow::Borrowed("updateAppRawSource"),
description: Cow::Borrowed("update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so)"),
instructions: Cow::Borrowed("Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead."),
path: Cow::Borrowed("/w/{workspace}/apps/update_raw_source/{path}"),
method: Cow::Borrowed("POST"),
path_params_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string"
}
},
"required": [
"path"
]
})),
query_params_schema: None,
body_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"summary": {
"type": "string"
},
"value": {
"type": "object",
"description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.",
"properties": {
"files": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"runnables": {
"type": "object"
},
"data": {
"type": "object"
}
},
"required": [
"files"
]
},
"policy": {
"type": "object",
"properties": {
"triggerables": {
"type": "object",
"additionalProperties": {
"type": "object"
}
},
"triggerables_v2": {
"type": "object",
"additionalProperties": {
"type": "object"
}
},
"s3_inputs": {
"type": "array",
"items": {
"type": "object"
}
},
"allowed_s3_keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"s3_path": {
"type": "string"
},
"resource": {
"type": "string"
}
}
}
},
"execution_mode": {
"type": "string",
"description": "Possible values: viewer, publisher, anonymous"
},
"on_behalf_of": {
"type": "string"
},
"on_behalf_of_email": {
"type": "string"
},
"sandbox": {
"type": "boolean",
"description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n"
},
"frontend_sdk_scopes": {
"type": "array",
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n"
}
}
},
"path__body": {
"type": "string",
"description": "(body parameter). Defaults to `path` when omitted; set it only to change the path."
}
},
"required": [
"value"
]
})),
query_field_renames: None,
body_field_renames: Some(serde_json::json!({
"path__body": "path"
})),
},
EndpointTool {
+78 -1
View File
@@ -551,7 +551,46 @@ fn jwt_scopes_for_proxied_route(
None,
)
})?;
Ok(Some(vec![scope]))
let mut scopes = vec![scope];
if let Some((tool, extras)) = extra_scopes_for_route(route_path) {
// Only when the token names this tool. `mcp:all` and `mcp:favorites` reach
// every endpoint without naming any, and they are the create-token and
// OAuth defaults — a token whose consent screen talks about scripts and
// flows must not come with this.
if caller_scopes.is_some_and(|s| selects_endpoint_tool(s, tool)) {
scopes.extend(extras.iter().map(|s| (*s).to_string()));
}
}
Ok(Some(scopes))
}
/// The tool a route belongs to and the scopes its handler requires beyond the
/// one the route's own domain implies, added to the JWT minted for that single
/// proxied request.
///
/// Minting is what keeps the grant *confined*: the JWT is built here and handed
/// to the internal request, never to the client, so the MCP token itself stays
/// `mcp:`-only and can't reach `/jobs/run/preview`. Putting the scope on the
/// token instead would widen every request it makes, which is a far larger grant
/// than the tool needs.
fn extra_scopes_for_route(route_path: &str) -> Option<(&'static str, &'static [&'static str])> {
// Matched on segments: a runnable path is caller-chosen and can contain
// anything, so a script called `f/apps/update_raw_source/x` must not decide
// what its own request is minted.
let mut segments = route_path.split('/').skip_while(|s| *s != "w");
let (_, _ws) = (segments.next()?, segments.next()?);
// Compiling an app's sources runs the app's own dependencies on a worker, so
// a token reaches that capability only by naming this tool.
(segments.next() == Some("apps") && segments.next() == Some("update_raw_source"))
.then_some(("updateAppRawSource", &["jobs:run"]))
}
/// Whether the token names `tool` rather than reaching it through a blanket
/// grant. Parsed by `windmill-mcp`, which owns the scope grammar; `mcp:all` and
/// `mcp:favorites` yield `*` or nothing, neither of which names a tool.
fn selects_endpoint_tool(caller_scopes: &[String], tool: &str) -> bool {
windmill_mcp::common::scope::parse_mcp_scopes(caller_scopes)
.is_ok_and(|config| config.endpoints.iter().any(|e| e == tool))
}
/// Create HTTP request with authentication
@@ -668,6 +707,44 @@ mod tests {
);
}
#[test]
fn proxy_jwt_raw_app_source_deploy_needs_the_tool_named() {
// The handler requires jobs:run as well: compiling an app's sources runs
// its dependencies on a worker. It goes in the per-request JWT, not on the
// token — the token stays mcp:-only and so can't reach /jobs/run/preview —
// and only for a token that named this tool. The defaults (mcp:favorites
// on create, mcp:all through OAuth) reach every endpoint without naming
// one, and must not carry a capability their consent screen never showed.
let route = "/api/w/ws/apps/update_raw_source/u/admin/app";
let named = scopes(&["mcp:endpoints:listScripts,updateAppRawSource"]);
assert_eq!(
jwt_scopes_for_proxied_route(Some(&named), "POST", route).unwrap(),
Some(scopes(&["apps:write", "jobs:run"]))
);
for implicit in [
scopes(&["mcp:all"]),
scopes(&["mcp:favorites"]),
scopes(&["mcp:endpoints:*"]),
] {
assert_eq!(
jwt_scopes_for_proxied_route(Some(&implicit), "POST", route).unwrap(),
Some(scopes(&["apps:write"])),
"a token that never named the tool must not get jobs:run"
);
}
// A runnable path is caller-chosen, so it must not select the extras of a
// route it merely spells out.
assert_eq!(
jwt_scopes_for_proxied_route(
Some(&named),
"POST",
"/api/w/ws/jobs/run/p/f/apps/update_raw_source/x"
)
.unwrap(),
Some(scopes(&["jobs:run:scripts"]))
);
}
#[test]
fn proxy_jwt_mixed_token_passes_through_caller_route_scope() {
// The caller's route scope is preserved so the target handler's per-path
+2
View File
@@ -16,6 +16,7 @@ import { getWmillYamlPath, mergeConfigWithConfigFile } from "../../core/conf.ts"
import { readInlinePathSync } from "../../utils/utils.ts";
import devCommand from "./dev.ts";
import lintCommand from "./lint.ts";
import bundleCommand from "./bundle_command.ts";
import newCommand from "./new.ts";
import generateAgentsCommand from "./generate_agents.ts";
import { isVersionsGeq1585 } from "../sync/global.ts";
@@ -436,6 +437,7 @@ const command = new Command()
.action(push as any)
.command("dev", devCommand)
.command("lint", lintCommand)
.command("bundle", bundleCommand)
.command("new", newCommand)
.command("generate-agents", generateAgentsCommand)
.command(
+72
View File
@@ -0,0 +1,72 @@
import { Command } from "@cliffy/command";
import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import * as log from "../../core/log.ts";
import { colors } from "@cliffy/ansi/colors";
import { createBundle, detectFrameworks } from "./bundle.ts";
interface BundleOptions {
out?: string;
minify?: boolean;
}
/** Every entry point a raw app may have, most specific first, with the
* framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */
function entryPointFor(appDir: string): string {
const frameworks = detectFrameworks(appDir);
const candidates = frameworks.svelte || frameworks.vue
? ["index.ts", "index.tsx", "index.js"]
: ["index.tsx", "index.ts", "index.js"];
const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c)));
if (!entry) {
throw new Error(
`No entry point in ${appDir}: expected one of ${candidates.join(", ")}`,
);
}
return path.join(appDir, entry);
}
/**
* Bundle a raw app folder without deploying it. `wmill app push` bundles as part
* of deploying; this exposes the same build on its own, so anything that needs a
* raw app's js/css — the server-side bundler behind `/apps/update_raw_source`,
* most of all — runs this exact build rather than reimplementing it.
*
* The result goes to files, not stdout: the build logs to stdout as it runs, so
* a caller can't read the bundle off the pipe.
*/
async function bundleApp(opts: BundleOptions, appFolder?: string) {
const appDir = path.resolve(appFolder ?? process.cwd());
const { js, css } = await createBundle({
entryPoint: entryPointFor(appDir),
production: true,
minify: opts.minify ?? true,
// Same as `wmill app push`: the workspace's shared UI is `ui/` under the
// directory the command runs from, not under the app.
sharedUiDir: path.join(process.cwd(), "ui"),
// createBundle removes its own outDir when it is done, and it resolves the
// path against cwd — so name one that is ours to delete rather than let it
// default to `dist`, which is very likely the caller's.
outDir: `.wmill-bundle-${process.pid}`,
});
const outDir = path.resolve(opts.out ?? path.join(appDir, "dist"));
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, "bundle.js"), js);
fs.writeFileSync(path.join(outDir, "bundle.css"), css);
log.info(colors.green(`Wrote bundle.js and bundle.css to ${outDir}`));
}
const command = new Command()
.description("Bundle a raw app folder to js/css without deploying it")
.arguments("[app_folder:string]")
.option(
"--out <dir:string>",
"Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)",
)
.option("--no-minify", "Skip minification")
.action(bundleApp as any);
export default command;
+3
View File
@@ -6737,6 +6737,9 @@ app related commands
- \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability
- \`--fix\` - Attempt to fix common issues (not implemented yet)
- \`app bundle [app_folder:string]\` - Bundle a raw app folder to js/css without deploying it
- \`--out <dir:string>\` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
- \`--no-minify\` - Skip minification
- \`app new\` - create a new raw app from a template
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
+118 -1
View File
@@ -1121,7 +1121,7 @@ export const mcpEndpointTools: EndpointTool[] = [
{
name: "updateApp",
description: "update app",
instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead.",
instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those.",
path: "/w/{workspace}/apps/update/{path}",
method: "POST",
pathParamsSchema: {
@@ -1160,6 +1160,123 @@ export const mcpEndpointTools: EndpointTool[] = [
queryFieldRenames: undefined,
bodyFieldRenames: {
"path__body": "path"
}
},
{
name: "updateAppRawSource",
description: "update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so)",
instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead.",
path: "/w/{workspace}/apps/update_raw_source/{path}",
method: "POST",
pathParamsSchema: {
"type": "object",
"properties": {
"path": {
"type": "string"
}
},
"required": [
"path"
]
},
queryParamsSchema: undefined,
bodySchema: {
"type": "object",
"properties": {
"summary": {
"type": "string"
},
"value": {
"type": "object",
"description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.",
"properties": {
"files": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"runnables": {
"type": "object"
},
"data": {
"type": "object"
}
},
"required": [
"files"
]
},
"policy": {
"type": "object",
"properties": {
"triggerables": {
"type": "object",
"additionalProperties": {
"type": "object"
}
},
"triggerables_v2": {
"type": "object",
"additionalProperties": {
"type": "object"
}
},
"s3_inputs": {
"type": "array",
"items": {
"type": "object"
}
},
"allowed_s3_keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"s3_path": {
"type": "string"
},
"resource": {
"type": "string"
}
}
}
},
"execution_mode": {
"type": "string",
"description": "Possible values: viewer, publisher, anonymous"
},
"on_behalf_of": {
"type": "string"
},
"on_behalf_of_email": {
"type": "string"
},
"sandbox": {
"type": "boolean",
"description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n"
},
"frontend_sdk_scopes": {
"type": "array",
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n"
}
}
},
"path__body": {
"type": "string",
"description": "(body parameter). Defaults to `path` when omitted; set it only to change the path."
}
},
"required": [
"value"
]
},
queryFieldRenames: undefined,
bodyFieldRenames: {
"path__body": "path"
}
},
{
@@ -35,6 +35,9 @@ app related commands
- `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability
- `--fix` - Attempt to fix common issues (not implemented yet)
- `app bundle [app_folder:string]` - Bundle a raw app folder to js/css without deploying it
- `--out <dir:string>` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
- `--no-minify` - Skip minification
- `app new` - create a new raw app from a template
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
+3
View File
@@ -2935,6 +2935,9 @@ app related commands
- \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability
- \`--fix\` - Attempt to fix common issues (not implemented yet)
- \`app bundle [app_folder:string]\` - Bundle a raw app folder to js/css without deploying it
- \`--out <dir:string>\` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
- \`--no-minify\` - Skip minification
- \`app new\` - create a new raw app from a template
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
@@ -40,6 +40,9 @@ app related commands
- `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability
- `--fix` - Attempt to fix common issues (not implemented yet)
- `app bundle [app_folder:string]` - Bundle a raw app folder to js/css without deploying it
- `--out <dir:string>` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
- `--no-minify` - Skip minification
- `app new` - create a new raw app from a template
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.