diff --git a/CHANGELOG.md b/CHANGELOG.md index ac41734dda..5272d8ec38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21) + + +### Features + +* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d)) +* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235)) +* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d)) +* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68)) +* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee)) +* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547)) + + +### Bug Fixes + +* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44)) +* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf)) +* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1)) + ## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20) diff --git a/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json b/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json new file mode 100644 index 0000000000..5321166c27 --- /dev/null +++ b/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT last_locked_at FROM concurrency_locks WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_locked_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1f871d5afb..b1a804bed3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -4315,9 +4315,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.705.0" +version = "1.706.0" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.705.0" +version = "1.706.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.705.0" +version = "1.706.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.705.0" +version = "1.706.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.705.0" +version = "1.706.0" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.705.0" +version = "1.706.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 50c9b7501a..41115db17e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.705.0" +version = "1.706.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.705.0" +version = "1.706.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 142ca727a7..3e4e2b57c5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -daffe7bb81cfcaca666c61de1ee838a44d60ebc2 +eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 89f0fd74c8..e3beab7f1c 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 901abe9fd7..566f9b1e58 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.705.0" +version = "1.706.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index a322b716b1..ecc49c22f6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -51,14 +51,15 @@ use windmill_common::{ JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, - OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, - POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, - REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, - UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, + NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, + NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, + PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, + PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, + REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, + TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -127,10 +128,10 @@ use crate::monitor::{ reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_instance_events_webhook_setting, reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, - reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmpfs_size_setting, - reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, - reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, - reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, + reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, + reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, + reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1785,6 +1786,7 @@ async fn process_notify_event( JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await, JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, + NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 70575aeb21..6f33e6255f 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -62,13 +62,13 @@ use windmill_common::{ HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, - NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, + NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, + OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, + POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, + REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, + UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, }, indexer::load_indexer_config, jwt::JWT_SECRET, @@ -108,9 +108,9 @@ use windmill_worker::{ BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, - NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, - POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -387,6 +387,7 @@ pub async fn initial_load( reload_job_default_timeout_setting(&conn).await; reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; + reload_nsjail_tmp_backing_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -1909,6 +1910,16 @@ pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) { .await; } +pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + NSJAIL_TMP_BACKING_SETTING, + "NSJAIL_TMP_BACKING", + NSJAIL_TMP_BACKING.clone(), + ) + .await; +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { diff --git a/backend/windmill-api-integration-tests/tests/ai_routes.rs b/backend/windmill-api-integration-tests/tests/ai_routes.rs index 72b284fcd1..cfd93caef5 100644 --- a/backend/windmill-api-integration-tests/tests/ai_routes.rs +++ b/backend/windmill-api-integration-tests/tests/ai_routes.rs @@ -10,6 +10,10 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { builder.header("Authorization", "Bearer SECRET_TOKEN") } +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {token}")) +} + fn assert_2xx(status: u16, body: &str, endpoint: &str) { assert!( (200..300).contains(&status), @@ -106,3 +110,125 @@ async fn test_ai_proxy_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Regression test for WIN-1971: the AI proxy's X-Resource-Path header must +/// honour resource RLS so that a low-privilege user cannot point the proxy +/// at a resource they are not allowed to read. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ai_proxy_x_resource_path_enforces_rls(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let mock_port = start_mock_ai_api().await; + let mock_url = format!("http://127.0.0.1:{mock_port}/v1"); + + // Resource owned by test-user (admin). With default extra_perms {} the + // RLS `see_own` policy restricts SELECT to user `test-user`. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user/restricted_openai', $1::jsonb, 'openai', '{}', 'test-user')", + ) + .bind(json!({ + "api_key": "sk-secret-restricted", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + // Sanity-check: normal resource API rejects test-user-3 (non-admin) for the restricted path. + let resp = authed_with( + client().get(format!( + "http://localhost:{port}/api/w/test-workspace/resources/get/u/test-user/restricted_openai" + )), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert!( + resp.status().as_u16() >= 400, + "normal resource API should deny test-user-3 reading restricted resource, got {}", + resp.status() + ); + + // The vulnerability: as a non-admin user, point X-Resource-Path at the + // restricted resource. Must be rejected before the proxy fetches/uses it. + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status >= 400, + "non-admin user should be rejected when X-Resource-Path points at a resource they cannot read, got {status}: {body}", + ); + + // A resource the non-admin owns must still work through X-Resource-Path. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user-3/own_openai', $1::jsonb, 'openai', '{}', 'test-user-3')", + ) + .bind(json!({ + "api_key": "sk-self", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user-3/own_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "non-admin with X-Resource-Path on owned resource", + ); + + // Admin must still be able to use X-Resource-Path on any resource. + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "admin with X-Resource-Path on restricted resource", + ); + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6f8755da3a..46c2639ea4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.705.0 + version: 1.706.0 title: Windmill API contact: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 05bf995700..e7bc089d7c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -1,6 +1,7 @@ #[cfg(feature = "bedrock")] use crate::bedrock; use crate::db::{ApiAuthed, DB}; +use crate::utils::check_scopes; #[cfg(feature = "bedrock")] use axum::routing::get; @@ -669,6 +670,7 @@ async fn global_proxy( async fn proxy( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, mut ai_path)): Path<(String, String)>, method: Method, headers: HeaderMap, @@ -689,6 +691,16 @@ async fn proxy( .get("X-Resource-Path") .map(|v| v.to_str().unwrap_or("").to_string()); let is_user_specified_resource = forced_resource_path.is_some(); + + // When the caller supplies X-Resource-Path, the resource is treated as if it + // were being read through the normal resource API: scope and RLS checks must + // apply so that a low-privilege user cannot point the proxy at a restricted + // AI resource (e.g. one in a folder they cannot read) to exfiltrate the + // resource's provider credentials or use them via the proxy. + if let Some(resource_path) = forced_resource_path.as_ref() { + check_scopes(&authed, || format!("resources:read:{}", resource_path))?; + } + let request_config = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.config @@ -759,13 +771,32 @@ async fn proxy( ) }; - let resource = sqlx::query_scalar::<_, Option>>>( - "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", - ) - .bind(&resource_path) - .bind(&resource_workspace) - .fetch_optional(&db) - .await? + // For user-specified resources, fetch through an RLS-scoped + // connection so PostgreSQL row-level security enforces the same + // folder/group boundaries as the regular resource API. For the + // workspace/instance ai_config path, the resource_path was already + // validated by an admin/devops user when configuring the workspace, + // so the raw pool is used. + let resource = if is_user_specified_resource { + let mut tx = user_db.clone().begin(&authed).await?; + let res = sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + res + } else { + sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&db) + .await? + } .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?; diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 063f7d9a43..5b14d809bd 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -55,6 +55,9 @@ pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; pub const JOB_ISOLATION_SETTING: &str = "job_isolation"; pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; +pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; +pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; +pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index eca3acd456..1872b52140 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -223,6 +223,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub nsjail_tmpfs_size_mb: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub nsjail_tmp_backing: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub bun_install_min_release_age: Option, #[serde(skip_serializing_if = "Option::is_none")] pub uv_exclude_newer: Option, diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index 8880a565c7..e56ef66de0 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 98422abb87..4d6b398fda 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{TARGET}" diff --git a/backend/windmill-worker/nsjail/download.rust.config.proto b/backend/windmill-worker/nsjail/download.rust.config.proto index 0884034076..57e3c2d7e8 100644 --- a/backend/windmill-worker/nsjail/download.rust.config.proto +++ b/backend/windmill-worker/nsjail/download.rust.config.proto @@ -49,12 +49,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "/etc" diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 7df486c53a..11660a5372 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -66,12 +66,7 @@ mount { is_bind: false } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.yml" diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 1136c8d298..899f7caa1e 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -68,12 +68,7 @@ mount { -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.sh" diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 280ef8a7f9..43e4464beb 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -60,12 +60,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/package.json" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index b58d6c330f..c624e55c43 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -57,12 +57,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index 4fec8f3b2d..1e6de06c9c 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 3b0a635b0c..9ae43f5b52 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 40a5de69a1..6c517a5b39 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{NU_PATH}" diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 965ac7cd36..6910cbe1a0 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.php" diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index bab869c9c8..5fd758a813 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -64,12 +64,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.ps1" diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index dec9d6bae7..53d5a6c64d 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -54,12 +54,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/{MAIN}.py" diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index 0f5d71bf5a..bc4d74bcba 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 3b9509cea6..2527e785b6 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 06da7be662..070a76e997 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index f745bc1661..7b0ae3b3ea 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -30,7 +30,7 @@ use crate::{ bash_executor::BIN_BASH, common::{ build_command_with_isolation, check_executor_binary_exists, get_reserved_variables, - read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, @@ -1457,8 +1457,8 @@ mount {{ additional_python_paths_folders.as_str(), ) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 47f100bd2d..516c75fdba 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -41,7 +41,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -216,8 +216,8 @@ exit $exit_status .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 2346cb413f..1e4f4aafd6 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -16,7 +16,7 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmpfs_size_bytes, start_child_process, write_file_binary, MaybeLock, + resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -2186,8 +2186,8 @@ try {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 95c7063328..9864093cd0 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -15,6 +15,7 @@ use tokio::process::Command; use tokio::{fs::File, io::AsyncReadExt}; use windmill_common::flows::Step; +use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK; use windmill_common::variables::{build_crypt_with_key_suffix, decrypt}; use windmill_common::worker::{ to_raw_value, update_ping_for_failed_init_script_query, write_file, Connection, Ping, PingType, @@ -48,7 +49,8 @@ use tokio::{io::AsyncWriteExt, time::Instant}; use crate::agent_workers::UPDATE_PING_URL; use crate::{ - JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, PATH_ENV, + JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, + NSJAIL_TMP_BACKING, PATH_ENV, }; use windmill_common::client::AuthedClient; @@ -1023,6 +1025,279 @@ pub async fn resolve_nsjail_tmpfs_size_bytes() -> String { } } +/// Sub-directory inside each job dir used as the disk-backed `/tmp` when +/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing +/// job-dir cleanup removes it for free. +const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp"; + +fn tmpfs_mount_block(size_bytes: &str) -> String { + format!( + "mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}" + ) +} + +fn bind_mount_block(jail_tmp: &str) -> String { + format!( + "mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}" + ) +} + +/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the +/// sandbox. +/// +/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job +/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this +/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with +/// `rw: true`. Callers must not pass user-controlled paths. +/// +/// Some executors (e.g. the bun codebase path) extract user-supplied archives +/// into `job_dir` before this resolver runs, so the resolver actively refuses +/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to +/// avoid bind-mounting an attacker-controlled host directory as `/tmp`. +/// +/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a +/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the +/// directory. If creation or the pre-existence check fails, logs an error and +/// falls back to the historical tmpfs block so the job can still start. For +/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the +/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`. +pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String { + let disk_backed = NSJAIL_TMP_BACKING + .read() + .await + .as_deref() + .map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK)) + .unwrap_or(false); + let size_bytes = resolve_nsjail_tmpfs_size_bytes().await; + if !disk_backed { + return tmpfs_mount_block(&size_bytes); + } + let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + // SECURITY: never bind-mount a symlinked (or otherwise non-directory) + // entry at jail_tmp. `symlink_metadata` returns the link's own metadata + // without following it, so `is_dir()` is true only for a real directory. + // User-controlled archives extracted into job_dir could otherwise plant + // `jail_tmp` as a symlink to an arbitrary host directory, which nsjail + // would then expose as a writable /tmp. + // + // A pre-existing real directory at this path is legitimate: several + // executors (python_executor, ruby_executor, rust_executor) invoke nsjail + // more than once per job_dir (e.g. dep install, then run), and the first + // invocation will have created it via the `create_dir` below. + match tokio::fs::symlink_metadata(&jail_tmp).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Err(e) = tokio::fs::create_dir(&jail_tmp).await { + tracing::error!( + "Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + Ok(meta) if meta.is_dir() => { + // Real directory left over from an earlier nsjail invocation in + // this same job_dir — safe to reuse. + } + Ok(_) => { + tracing::error!( + "Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \ + exists but is not a regular directory (possibly a symlink \ + planted by a user-controlled archive). Falling back to \ + RAM-backed tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + Err(e) => { + tracing::error!( + "Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + bind_mount_block(&jail_tmp) +} + +#[cfg(test)] +mod nsjail_tmp_mount_tests { + use super::*; + + #[test] + fn tmpfs_block_renders_size() { + let block = tmpfs_mount_block("800000000"); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=800000000\"")); + assert!(!block.contains("is_bind")); + } + + #[test] + fn bind_block_renders_source_path() { + let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp"); + assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\"")); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("is_bind: true")); + assert!(block.contains("rw: true")); + assert!(!block.contains("fstype")); + } + + /// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING` + /// so they don't race when cargo runs them in parallel. + static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + async fn with_tmp_backing(value: Option, f: F) -> T + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let _serial = SETTING_GUARD.lock().await; + let prev = NSJAIL_TMP_BACKING.read().await.clone(); + *NSJAIL_TMP_BACKING.write().await = value; + let res = f().await; + *NSJAIL_TMP_BACKING.write().await = prev; + res + } + + #[tokio::test] + async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() { + let block = with_tmp_backing(Some("tmpfs".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=")); + assert!(!block.contains("is_bind")); + } + + #[tokio::test] + async fn unset_defaults_to_tmpfs() { + let block = with_tmp_backing(None, || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and + /// emit a bind block pointing at it. + #[tokio::test] + async fn disk_backed_creates_jail_tmp_and_returns_bind_block() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + assert!( + std::path::Path::new(&expected_dir).is_dir(), + "jail_tmp dir should have been created at {expected_dir}" + ); + assert!(block.contains("is_bind: true")); + assert!(block.contains(&format!("src: \"{expected_dir}\""))); + } + + /// Disk-backed branch fallback: if `create_dir_all` fails, we must emit + /// the tmpfs block instead of returning an invalid bind config. + #[tokio::test] + async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() { + // /proc is a kernel filesystem that disallows directory creation, + // so create_dir_all on a subpath returns EPERM/EACCES. + let job_dir = "/proc/win1967_should_not_exist"; + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(job_dir).await + }) + .await; + + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Unknown values fall through to the tmpfs branch instead of crashing. + #[tokio::test] + async fn unknown_value_defaults_to_tmpfs() { + let block = with_tmp_backing(Some("bogus".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Security regression: if a pre-existing symlink sits at the jail_tmp + /// path (e.g. planted by a user-controlled tarball extracted into + /// `job_dir` before the resolver runs), the resolver must refuse the + /// bind mount and fall back to tmpfs — never bind-mount the symlink + /// target into the sandbox as /tmp. + #[cfg(unix)] + #[tokio::test] + async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + // Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host + // path. Target doesn't have to exist — what matters is that the + // resolver doesn't follow it. + let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink"); + assert!(std::path::Path::new(&jail_tmp_path).is_symlink()); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + // Fell back to tmpfs — no bind-mount of the attacker-controlled path. + assert!( + block.contains("fstype: \"tmpfs\""), + "expected tmpfs fallback, got: {block}" + ); + assert!( + !block.contains("is_bind"), + "must not emit bind block, got: {block}" + ); + assert!( + !block.contains("/etc"), + "must not leak the symlink target into the proto, got: {block}" + ); + } + + /// Sequential resolver calls in the same `job_dir` (e.g. Python uv install + /// → Python run, Ruby install → run, Rust build → run) must keep using + /// the bind mount instead of silently falling back to tmpfs on the + /// second call. The first call creates `jail_tmp`; subsequent calls see + /// it as a pre-existing real directory and must accept it. + #[tokio::test] + async fn disk_backed_reuses_jail_tmp_across_sequential_calls() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + let (first, second) = with_tmp_backing(Some("disk".to_string()), || async { + let first = resolve_nsjail_tmp_mount_block(&job_dir).await; + // Simulate an executor that completes its first nsjail invocation + // (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail + // again for the main run. + assert!(std::path::Path::new(&expected_dir).is_dir()); + let second = resolve_nsjail_tmp_mount_block(&job_dir).await; + (first, second) + }) + .await; + + assert!(first.contains("is_bind: true"), "first call: {first}"); + assert!( + second.contains("is_bind: true"), + "second call regressed to tmpfs: {second}" + ); + assert!(second.contains(&format!("src: \"{expected_dir}\""))); + } +} + async fn hash_args( #[allow(unused)] db: &DB, #[allow(unused)] client: &AuthedClient, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index cfd9a544a1..70df9fb4e0 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -27,7 +27,7 @@ use windmill_queue::CanceledBy; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -604,8 +604,8 @@ pub async fn handle_csharp_job( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index ee08aa1b30..c10e41752c 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, handle_child::handle_child, @@ -352,8 +352,8 @@ func Run(req Req) (interface{{}}, error){{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index f927f921c8..35541c3ee3 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override, @@ -671,8 +671,8 @@ async fn run<'a>( // .replace("{CACHED_TARGET}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index d9a55c40c9..8aa0946666 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,7 +14,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, @@ -259,8 +259,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index e0ba015951..08a92b3641 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -426,8 +426,8 @@ try {{ .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index 5ec6ff6bcd..1ce2eb09b3 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -26,7 +26,7 @@ lazy_static::lazy_static! { use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -683,8 +683,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", .replace("{SHARED_MOUNT}", shared_mount) .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 2930c9d339..4bec8d44bf 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -146,7 +146,7 @@ use windmill_object_store::OBJECT_STORE_SETTINGS; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -1028,8 +1028,8 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; @@ -2056,8 +2056,8 @@ async fn spawn_uv_install( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .as_str(), )?; diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 19959ee22c..9b2bbc6874 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmpfs_size_bytes, start_child_process, OccupancyMetrics, + read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -582,8 +582,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), )?; diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index c82d5ad7df..7871c050c6 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -619,7 +619,7 @@ async fn install<'a>( envs.clone(), get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?, ); - let nsjail_tmpfs_size = resolve_nsjail_tmpfs_size_bytes().await; + let nsjail_tmp_mount_block = resolve_nsjail_tmp_mount_block(&job_dir).await; par_install_language_dependencies_seq( InstallDeps::Flat(deps.clone()), "ruby", @@ -639,7 +639,7 @@ async fn install<'a>( .replace("{TARGET}", &dependency.path) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("{NSJAIL_TMPFS_SIZE}", &nsjail_tmpfs_size) + .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); @@ -812,7 +812,10 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{NSJAIL_TMPFS_SIZE}", &resolve_nsjail_tmpfs_size_bytes().await) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 0f404569da..5941e9db1c 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -481,8 +481,8 @@ pub async fn build_rust_crate( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{BUILD}", &build_dir), )?; @@ -706,8 +706,8 @@ pub async fn handle_rust_job( .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b85c35d65b..6b1216ad9d 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -687,6 +687,12 @@ lazy_static::lazy_static! { /// `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES` (800MB). pub static ref NSJAIL_TMPFS_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + /// Selects how `/tmp` is backed inside nsjail sandboxes. `Some("disk")` + /// switches to a bind mount on `{JOB_DIR}/jail_tmp` (disk-backed); any + /// other value (including `None` or `Some("tmpfs")`) keeps the historical + /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. + pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9e041bc6ac..5d0acca54f 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.705.0"; +export const VERSION = "v1.706.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index a77596ae9f..fd6b93380d 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -16,13 +16,8 @@ import { type Workspace, } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; -import { - WMILL_INIT_AI_AGENTS_SOURCE_ENV, - WMILL_INIT_AI_CLAUDE_SOURCE_ENV, - WMILL_INIT_AI_SKILLS_SOURCE_ENV, - writeAiGuidanceFiles, -} from "../../guidance/writer.ts"; import { generateCommentedTemplate } from "./template.ts"; +import { refreshPrompts } from "../refresh/prompts.ts"; export interface InitOptions { useDefault?: boolean; @@ -241,45 +236,7 @@ async function initAction(opts: InitOptions) { } } - // Read nonDottedPaths from config - let nonDottedPaths = true; // default for new inits - try { - const { readConfigFile } = await import("../../core/conf.ts"); - const config = await readConfigFile(); - nonDottedPaths = config.nonDottedPaths ?? true; - } catch { - // If config can't be read, use defaults - } - - // Create guidance files (AGENTS.md, CLAUDE.md, and agent skills) - try { - const guidanceResult = await writeAiGuidanceFiles({ - targetDir: ".", - nonDottedPaths, - overwriteProjectGuidance: false, - skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV], - agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], - claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], - }); - - if (guidanceResult.agentsWritten) { - log.info(colors.green("Created AGENTS.md")); - } - if (guidanceResult.claudeWritten) { - log.info(colors.green("Created CLAUDE.md")); - } - log.info( - colors.green( - `Created .claude/skills/ and .agents/skills/ with ${guidanceResult.skillCount} skills` - ) - ); - } catch (error) { - if (error instanceof Error) { - log.warn(`Could not create guidance files: ${error.message}`); - } else { - log.warn(`Could not create guidance files: ${error}`); - } - } + await refreshPrompts({ yes: opts.useDefault === true }); // Generate resource type namespace (only if a workspace was bound) if (didBindWorkspace && boundProfile) { diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts new file mode 100644 index 0000000000..3f933d3de2 --- /dev/null +++ b/cli/src/commands/refresh/prompts.ts @@ -0,0 +1,182 @@ +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Select } from "@cliffy/prompt/select"; +import * as log from "../../core/log.ts"; +import { + type AgentsMdMigration, + type ReconcileOutcome, + WMILL_INIT_AI_AGENTS_SOURCE_ENV, + WMILL_INIT_AI_CLAUDE_SOURCE_ENV, + WMILL_INIT_AI_SKILLS_SOURCE_ENV, + writeAiGuidanceFiles, +} from "../../guidance/writer.ts"; + +/** + * Programmatic entry point reused by `wmill init`. The init flow doesn't + * register the cliffy command itself — it imports and calls this directly so + * that prompt regeneration is part of every init. + */ +export async function refreshPrompts(opts: { + yes?: boolean; +}): Promise { + // Match `core/conf.ts`'s missing-key default (`?? false`) so legacy + // wmill.yaml files without the key don't drift from how sync renders + // paths. New projects get `true` via the wmill.yaml template, not via + // this fallback. + let nonDottedPaths = false; + try { + const { readConfigFile } = await import("../../core/conf.ts"); + const config = await readConfigFile(); + nonDottedPaths = config.nonDottedPaths ?? false; + } catch { + // If config can't be read, use the conservative default above. + } + + const interactive = process.stdin.isTTY && !opts.yes; + + try { + const result = await writeAiGuidanceFiles({ + targetDir: ".", + nonDottedPaths, + skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV], + agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], + claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], + resolveAgentsMdMigration: async () => { + if (!interactive) return "append"; + return await promptMigration(); + }, + }); + + log.info(colors.green("Refreshed AGENTS.cli.md")); + + reportReconciliation({ + file: "AGENTS.md", + includeLine: "@AGENTS.cli.md", + created: result.agentsCreated, + migration: result.agentsMigration, + }); + + reportReconciliation({ + file: "CLAUDE.md", + includeLine: "@AGENTS.md", + created: result.claudeCreated, + migration: result.claudeMigration, + }); + + log.info( + colors.green( + `Refreshed .claude/skills/ and .agents/skills/ with ${result.skillCount} skills` + ) + ); + log.info( + colors.gray( + "Project-specific instructions live in AGENTS.md (never overwritten unless you opt in)." + ) + ); + } catch (error) { + // Log first so the user sees what happened, then rethrow so `wmill + // refresh prompts` (and `wmill init`, which delegates here) exits + // non-zero. Silent swallowing would hide a broken refresh from CI. + if (error instanceof Error) { + log.error(`Could not refresh guidance files: ${error.message}`); + } else { + log.error(`Could not refresh guidance files: ${error}`); + } + throw error; + } +} + +function reportReconciliation(opts: { + file: string; + includeLine: string; + created: boolean; + migration: ReconcileOutcome; +}): void { + if (opts.created) { + log.info(colors.green(`Created ${opts.file} (user-owned)`)); + return; + } + switch (opts.migration) { + case "already-linked": + log.info( + colors.gray( + `${opts.file} already references ${opts.includeLine} — left as-is` + ) + ); + break; + case "append": + log.info( + colors.green(`Appended ${opts.includeLine} include to existing ${opts.file}`) + ); + break; + case "overwrite": + log.info(colors.yellow(`Overwrote ${opts.file} with managed skeleton`)); + break; + case "skip": + log.info( + colors.gray( + `${opts.file} left unchanged — wire \`${opts.includeLine}\` in manually when ready` + ) + ); + break; + case "not-applicable": + // unreachable when created is false, but keep exhaustive + break; + } +} + +async function promptMigration(): Promise { + log.info(""); + log.info( + colors.yellow( + "An existing AGENTS.md or CLAUDE.md was found that does not reference Windmill's managed guidance." + ) + ); + log.info( + colors.gray( + "Choose how to link the managed files in (we'll apply the same choice to AGENTS.md and CLAUDE.md):" + ) + ); + + const choice = await Select.prompt({ + message: "How should we handle the existing file(s)?", + options: [ + { + name: + "Append the include line " + + "(preserves your content — recommended if you have custom instructions)", + value: "append", + }, + { + name: + "Overwrite with the managed skeleton " + + "(replaces your content — pick if the file only had the default template)", + value: "overwrite", + }, + { + name: "Skip — leave the file alone; I'll wire it up manually", + value: "skip", + }, + ], + }); + + return choice as AgentsMdMigration; +} + +interface CommandOptions { + yes?: boolean; +} + +async function promptsAction(opts: CommandOptions): Promise { + await refreshPrompts({ yes: opts.yes === true }); +} + +const command = new Command() + .description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") + .option( + "--yes", + "Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include." + ) + .action(promptsAction as any); + +export default command; diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts new file mode 100644 index 0000000000..882284c428 --- /dev/null +++ b/cli/src/commands/refresh/refresh.ts @@ -0,0 +1,8 @@ +import { Command } from "@cliffy/command"; +import promptsCommand from "./prompts.ts"; + +const command = new Command() + .description("Refresh wmill-managed project files (AGENTS.cli.md and skills)") + .command("prompts", promptsCommand); + +export default command; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index fe7bc3011a..de65b33343 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -1,17 +1,55 @@ /** - * Core guidance content for AGENTS.md + * Core guidance content for the AGENTS files Windmill writes during init. * - * This module exports the template for the AGENTS.md file that provides - * AI agent instructions for working with Windmill projects. + * `wmill` writes two files: + * + * - `AGENTS.cli.md` — managed CLI / workspace guidance, refreshed by + * `wmill refresh prompts` (and the implicit refresh inside `wmill init`). + * - `AGENTS.md` — user-owned project entry point. The default skeleton + * references `AGENTS.cli.md` via an `@`-include so the managed content is + * pulled in automatically. */ +export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md"; + /** - * Generate the AGENTS.md content with the given skills reference. - * @param skillsReference - A formatted list of skills to include in the document - * @returns The complete AGENTS.md content + * Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md + * exists in the project. Everything below the `@AGENTS.cli.md` include is for + * the user to edit; nothing in this file is refreshed by `wmill`. */ -export function generateAgentsMdContent(skillsReference: string): string { - return `# Windmill AI Agent Instructions +export function generateAgentsMdSkeleton(): string { + return `# Project AI Agent Instructions + +This file is the entry point for AI agents working in this repository. It is +**user-owned** — \`wmill\` never overwrites it. Add your project-specific +guidance below the include line. + +The line below pulls in Windmill's managed CLI guidance (skills, deploy flow, +debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the +include line if you don't want the managed guidance in this project. + +${AGENTS_CLI_INCLUDE_LINE} + +## Project-specific instructions + + +`; +} + +/** + * Managed AGENTS.cli.md content. Rewritten by `wmill init` and + * `wmill refresh prompts` every time. + */ +export function generateAgentsCliMdContent(skillsReference: string): string { + return `# Windmill CLI Agent Instructions + +> Managed by \`wmill\`. This file is regenerated on \`wmill init\` and +> \`wmill refresh prompts\` — edit AGENTS.md (user-owned) for project-specific +> instructions instead. You are a helpful assistant that can help with Windmill scripts, flows, apps, and resources management. @@ -55,6 +93,50 @@ You MUST use the \`preview\` skill any time the user wants to see/open/visualize You MUST use the \`cli-commands\` skill to use the CLI. +## Running and previewing local changes + +Local previews exist for every entity type and don't deploy: + +- \`wmill script preview -d ''\` — run a local script. +- \`wmill flow preview -d ''\` — run a local flow.yaml. +- \`wmill app dev\` — live-reload dev server for raw apps. + +Argument shapes and per-language details live in the \`write-script-\`, \`write-flow\`, and \`raw-app\` skills. + +## Deploying + +There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit. + +### Detecting the setup + +Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy: + +- Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar. +- Cache the result for the rest of the session — don't re-scan on every deploy. + +If such a workflow exists → **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B). + +### Option A — \`git push\` (CI is wired to sync) + +The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it. + +1. \`git add\` + \`git commit\` the local changes. +2. \`git push\` to the branch the CI runs on. +3. The workflow deploys to the workspace. + +Only fall back to Option B if the user explicitly asks to bypass CI for this change (e.g. CI is broken, urgent hotfix), or if the workflow doesn't cover the current branch. + +### Option B — \`wmill sync push\` (no CI wiring) + +No CI workflow runs \`wmill sync push\` automatically, so deploy directly from the CLI: + +- \`wmill sync push --dry-run\` to preview. +- \`wmill sync push\` to apply. + +### In both cases + +Only deploy when the user explicitly asks to deploy, publish, push, or ship — not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy. + ## Debugging Jobs When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags. @@ -70,7 +152,7 @@ For flow failures, start with \`wmill job get \` to identify the failing ste ## Skills -For specific guidance, ALWAYS use the skills listed below. +For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`. ${skillsReference} `; diff --git a/cli/src/guidance/freshness.ts b/cli/src/guidance/freshness.ts new file mode 100644 index 0000000000..5104f7c0e6 --- /dev/null +++ b/cli/src/guidance/freshness.ts @@ -0,0 +1,169 @@ +/** + * Versioning + freshness check for the managed AGENTS.cli.md bundle. + * + * We embed a short hash of "what this CLI would write" into AGENTS.cli.md as + * an HTML comment. On every `wmill` command (with a few exceptions), we read + * the stored hash and compare against the current CLI's hash. Mismatch => + * one-line warning telling the user to `wmill refresh prompts`. + * + * The hash covers all inputs that affect the rendered bundle: the + * AGENTS.cli.md template, every skill body, schemas and schema mappings, and + * the nonDottedPaths setting. It is *not* tied to the CLI's package version, + * so non-prompt CLI releases don't produce false positives. + */ +import { createHash } from "node:crypto"; +import { stat } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { readTextFile } from "../utils/utils.ts"; +import { generateAgentsCliMdContent } from "./core.ts"; +import { + SCHEMAS, + SCHEMA_MAPPINGS, + SKILLS, + SKILL_CONTENT, +} from "./skills.gen.ts"; + +// Re-export from the gate module so existing callers (and tests) keep working. +// `shouldRunFreshnessCheck` lives there to avoid pulling skills.gen.ts (~360 KB) +// into main.ts's static import graph; main.ts now imports the gate directly +// and only `await import`s this file lazily. +import { shouldRunFreshnessCheck } from "./freshness_gate.ts"; +export { shouldRunFreshnessCheck }; + +export const PROMPTS_HASH_MARKER_PREFIX = "/; + +export function buildPromptsHashMarker(hash: string): string { + return `${PROMPTS_HASH_MARKER_PREFIX}${hash} -->`; +} + +export function extractPromptsHash(content: string): string | null { + const match = content.match(PROMPTS_HASH_REGEX); + return match ? match[1] : null; +} + +/** + * Insert the hash marker into rendered AGENTS.cli.md content. The marker + * goes on the line right after the title so it's easy to find and doesn't + * break the rendered Markdown structure. + */ +export function injectPromptsHashMarker( + content: string, + hash: string +): string { + const lines = content.split("\n"); + const marker = buildPromptsHashMarker(hash); + // Insert right after the first line if it's an H1 title; otherwise + // prepend so the marker is always near the top. + const insertAt = lines[0].startsWith("# ") ? 1 : 0; + lines.splice(insertAt, 0, marker); + return lines.join("\n"); +} + +/** + * Compute the hash for the rendered bundle. The hash is deterministic for a + * given (CLI bundle, nonDottedPaths) pair. + */ +export function currentPromptsHash(nonDottedPaths: boolean): string { + const hasher = createHash("sha256"); + + // Template structure (without the skills reference — that's hashed + // separately from the SKILLS metadata). + hasher.update("template:"); + hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__")); + + // Skill metadata (names + descriptions) — fed into the skills reference + // line in AGENTS.cli.md and the wrapper frontmatter. + hasher.update("\nskills:"); + hasher.update(JSON.stringify(SKILLS)); + + // Skill bodies — what actually lands in .agents/skills//SKILL.md. + // Sort entries for stable ordering. + hasher.update("\nbodies:"); + for (const [name, content] of Object.entries(SKILL_CONTENT).sort()) { + hasher.update("\n"); + hasher.update(name); + hasher.update("\n"); + hasher.update(content); + } + + // Schemas + their mappings — embedded inside specific skills. + hasher.update("\nschemas:"); + hasher.update(JSON.stringify(SCHEMAS)); + hasher.update("\nmappings:"); + hasher.update(JSON.stringify(SCHEMA_MAPPINGS)); + + // Path-style setting — controls __flow vs .flow rendering in skill bodies. + hasher.update("\nnonDotted:"); + hasher.update(String(nonDottedPaths)); + + return hasher.digest("hex").slice(0, 12); +} + +/** + * Read AGENTS.cli.md in the current working directory, compare its embedded + * hash to the current CLI's hash, and print a one-line warning if they + * differ. Silent on every other code path (no AGENTS.cli.md, no marker, + * matching hash, IO error, …) so it never gets in the user's way. + */ +export async function warnIfPromptsStale(opts?: { + cwd?: string; + nonDottedPaths?: boolean; + argv?: readonly string[]; +}): Promise { + if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return; + + const cwd = opts?.cwd ?? process.cwd(); + const path = `${cwd}/AGENTS.cli.md`; + + if (!(await stat(path).catch(() => null))) return; + + let content: string; + try { + content = await readTextFile(path); + } catch { + return; + } + + const stored = extractPromptsHash(content); + if (!stored) { + // Older AGENTS.cli.md without a marker. Warn so the user re-runs + // refresh and picks up the new format. + emitWarning( + "Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker." + ); + return; + } + + let nonDottedPaths = opts?.nonDottedPaths; + if (nonDottedPaths === undefined) { + try { + const { readConfigFile } = await import("../core/conf.ts"); + const config = await readConfigFile(); + // Match `core/conf.ts`'s missing-key default (`?? false`); otherwise + // legacy wmill.yaml files without the key trip a permanent freshness + // warning even though the prompts are objectively up to date. + nonDottedPaths = config.nonDottedPaths ?? false; + } catch { + nonDottedPaths = false; + } + } + + const current = currentPromptsHash(nonDottedPaths); + if (stored !== current) { + emitWarning( + "Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh." + ); + } +} + +/** + * Send the freshness warning to **stderr** so it never contaminates a + * downstream pipe (e.g. `wmill job result | jq`). The rest of the CLI + * uses `log.warn` which writes to stdout — that's wrong for an always-on + * notification like this one, but we don't want to fix `log.warn` globally + * in this PR. + */ +function emitWarning(message: string): void { + process.stderr.write(`${colors.yellow(message)}\n`); +} diff --git a/cli/src/guidance/freshness_gate.ts b/cli/src/guidance/freshness_gate.ts new file mode 100644 index 0000000000..0b5f7c8a17 --- /dev/null +++ b/cli/src/guidance/freshness_gate.ts @@ -0,0 +1,67 @@ +/** + * Argv-only gate for the prompts freshness check. Kept in its own module so + * `main.ts` can import it without pulling in the heavy `skills.gen.ts` + * bundle (~360 KB) on every `wmill` invocation. The full check (which does + * touch the bundle) lives in `./freshness.ts` and is loaded lazily after + * this gate returns `true`. + */ + +/** + * Subcommands where a freshness warning is noise (the user is either fixing + * it, asking for help, or doing something orthogonal). + */ +const SKIP_FRESHNESS_FOR_SUBCOMMANDS = new Set([ + "init", + "refresh", + "completions", + "upgrade", +]); + +/** + * Cliffy global options that consume the *next* argv element as their value. + * Must be kept in sync with the option declarations on the top-level + * `command` in `cli/src/main.ts`. + */ +const VALUE_GLOBAL_OPTS = new Set([ + "--workspace", + "--token", + "--base-url", + "--config-dir", +]); + +/** + * Returns `true` if the freshness check should run for this invocation. + * + * Bypasses: + * - bare `wmill` (no subcommand → shows help) + * - `--help`, `-h`, `--version`, `-V` anywhere in the args + * - subcommands in {init, refresh, completions, upgrade} + * + * Handles cliffy global options that take a value (`--workspace foo`, + * `--token tok`, `--base-url https://…`, `--config-dir /etc/wmill`) by + * skipping their value when scanning for the first positional argument. + * Without that, `wmill --workspace prod refresh prompts` would misread + * `"prod"` as the subcommand and fire the warning during the very command + * meant to fix it. + */ +export function shouldRunFreshnessCheck(argv: readonly string[]): boolean { + const args = argv.slice(2); // strip node + script + if (args.length === 0) return false; + if (args.includes("--help") || args.includes("-h")) return false; + if (args.includes("--version") || args.includes("-V")) return false; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + if (VALUE_GLOBAL_OPTS.has(arg)) { + i += 2; // skip flag + its value + continue; + } + if (arg.startsWith("-")) { + i += 1; // flag with no value + continue; + } + return !SKIP_FRESHNESS_FOR_SUBCOMMANDS.has(arg); + } + return false; +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8e8e447aed..de1b8bcdc6 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -7001,6 +7001,15 @@ List all queues with their metrics - \`--instance [instance]\` - Name of the instance to push to, override the active instance - \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index 8d6f4187b8..fcaae0671a 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -1,7 +1,15 @@ import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises"; import { readTextFile } from "../utils/utils.ts"; import { join } from "node:path"; -import { generateAgentsMdContent } from "./core.ts"; +import { + AGENTS_CLI_INCLUDE_LINE, + generateAgentsCliMdContent, + generateAgentsMdSkeleton, +} from "./core.ts"; +import { + currentPromptsHash, + injectPromptsHashMarker, +} from "./freshness.ts"; import { SCHEMAS, SCHEMA_MAPPINGS, @@ -14,18 +22,47 @@ type ResolvedSkillMetadata = SkillMetadata & { directoryName: string; }; +/** + * How to reconcile an existing user-owned guidance file (AGENTS.md or + * CLAUDE.md) that doesn't reference the managed file below it + * (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md). + * + * - `append`: leave the file as-is and append the include line. + * - `overwrite`: replace the file with the managed skeleton. + * - `skip`: leave the file alone. The managed downstream file is still + * written/refreshed, but no link to it — the user is expected to wire it + * manually later. + */ +export type AgentsMdMigration = "append" | "overwrite" | "skip"; + +export type ReconcileOutcome = + | AgentsMdMigration + | "already-linked" + | "not-applicable"; + export interface WriteAiGuidanceOptions { targetDir: string; nonDottedPaths?: boolean; - overwriteProjectGuidance?: boolean; + /** Skill source override (testing / source-of-truth bundling). */ skillsSourcePath?: string; + /** AGENTS.cli.md source override (testing). */ agentsSourcePath?: string; + /** CLAUDE.md source override (testing). */ claudeSourcePath?: string; + /** + * Optional resolver invoked when an existing AGENTS.md lacks an + * `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if + * omitted, the writer defaults to `append` (non-destructive). + */ + resolveAgentsMdMigration?: () => Promise; } export interface WriteAiGuidanceResult { - agentsWritten: boolean; - claudeWritten: boolean; + agentsCliWritten: boolean; + agentsCreated: boolean; + agentsMigration: ReconcileOutcome; + claudeCreated: boolean; + claudeMigration: ReconcileOutcome; skillCount: number; } @@ -34,32 +71,73 @@ export const WMILL_INIT_AI_AGENTS_SOURCE_ENV = "WMILL_INIT_AI_AGENTS_SOURCE"; export const WMILL_INIT_AI_CLAUDE_SOURCE_ENV = "WMILL_INIT_AI_CLAUDE_SOURCE"; const CLAUDE_MD_DEFAULT = "Instructions are in @AGENTS.md\n"; -const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const; +const CLAUDE_MD_INCLUDE_LINE = "@AGENTS.md"; + +/** + * Both `.agents/skills/` (read by Codex, Pi) and `.claude/skills/` (read by + * Claude Code) receive the full skill content. We can't use `@` to + * deduplicate because Claude's skill loader reads SKILL.md as-is — it does + * not expand `@` references inside skill bodies (those work only in + * AGENTS.md / CLAUDE.md). + */ +const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const; export async function writeAiGuidanceFiles( options: WriteAiGuidanceOptions ): Promise { - const nonDottedPaths = options.nonDottedPaths ?? true; + // Match `core/conf.ts`'s missing-key default — if a legacy wmill.yaml + // omits `nonDottedPaths`, sync treats it as `false`, so we must too or + // the freshness hash will be permanently out of sync with the rest of + // the CLI's view of the project. + const nonDottedPaths = options.nonDottedPaths ?? false; const skillMetadata = options.skillsSourcePath ? await readSkillMetadataFromDirectory(options.skillsSourcePath) : getGeneratedSkillMetadata(); - const agentsWritten = await writeProjectGuidanceFile({ - targetPath: join(options.targetDir, "AGENTS.md"), - overwrite: options.overwriteProjectGuidance ?? false, - content: - options.agentsSourcePath != null - ? await readTextFile(options.agentsSourcePath) - : generateAgentsMdContent(buildSkillsReference(skillMetadata)), + // AGENTS.cli.md — always (re)written, this is the managed file. + // We embed a content-hash marker so other `wmill` commands can detect a + // stale bundle and prompt the user to `wmill refresh prompts`. + const rawAgentsCliContent = + options.agentsSourcePath != null + ? await readTextFile(options.agentsSourcePath) + : generateAgentsCliMdContent(buildSkillsReference(skillMetadata)); + const agentsCliContent = injectPromptsHashMarker( + rawAgentsCliContent, + currentPromptsHash(nonDottedPaths) + ); + const agentsCliPath = join(options.targetDir, "AGENTS.cli.md"); + await writeFile(agentsCliPath, agentsCliContent, "utf8"); + const agentsCliWritten = true; + + // Cache the user's first migration answer and reuse it for every file + // that needs reconciling in this run — there's never a good reason to ask + // the same question twice in a row. + const resolveMigration = cacheOnce(options.resolveAgentsMdMigration); + + // AGENTS.md — user-owned. Three paths: + // 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md). + // 2. exists and already references @AGENTS.cli.md → leave alone. + // 3. exists but doesn't reference @AGENTS.cli.md → ask caller via + // resolveMigration (defaults to append). + const agentsMdResult = await reconcileIncludingFile({ + path: join(options.targetDir, "AGENTS.md"), + includeLine: AGENTS_CLI_INCLUDE_LINE, + skeleton: generateAgentsMdSkeleton(), + resolveMigration, }); - const claudeWritten = await writeProjectGuidanceFile({ - targetPath: join(options.targetDir, "CLAUDE.md"), - overwrite: options.overwriteProjectGuidance ?? false, - content: - options.claudeSourcePath != null - ? await readTextFile(options.claudeSourcePath) - : CLAUDE_MD_DEFAULT, + // CLAUDE.md — user-owned wrapper that points at @AGENTS.md. Same three-way + // reconciliation: create if missing, leave alone if it already references + // AGENTS.md, otherwise ask via resolveMigration. + const claudeSkeleton = + options.claudeSourcePath != null + ? await readTextFile(options.claudeSourcePath) + : CLAUDE_MD_DEFAULT; + const claudeMdResult = await reconcileIncludingFile({ + path: join(options.targetDir, "CLAUDE.md"), + includeLine: CLAUDE_MD_INCLUDE_LINE, + skeleton: claudeSkeleton, + resolveMigration, }); if (options.skillsSourcePath) { @@ -69,17 +147,87 @@ export async function writeAiGuidanceFiles( } return { - agentsWritten, - claudeWritten, + agentsCliWritten, + agentsCreated: agentsMdResult.created, + agentsMigration: agentsMdResult.migration, + claudeCreated: claudeMdResult.created, + claudeMigration: claudeMdResult.migration, skillCount: skillMetadata.length, }; } +function cacheOnce( + resolver: (() => Promise) | undefined +): (() => Promise) | undefined { + if (!resolver) return undefined; + let cached: AgentsMdMigration | null = null; + return async () => { + if (cached !== null) return cached; + cached = await resolver(); + return cached; + }; +} + +async function reconcileIncludingFile(options: { + path: string; + includeLine: string; + skeleton: string; + resolveMigration?: () => Promise; +}): Promise<{ created: boolean; migration: ReconcileOutcome }> { + const exists = (await stat(options.path).catch(() => null)) != null; + if (!exists) { + await writeFile(options.path, options.skeleton, "utf8"); + return { created: true, migration: "not-applicable" }; + } + + const existing = await readTextFile(options.path); + if (referencesIncludeLine(existing, options.includeLine)) { + return { created: false, migration: "already-linked" }; + } + + const choice = options.resolveMigration + ? await options.resolveMigration() + : "append"; + + if (choice === "skip") { + return { created: false, migration: "skip" }; + } + + if (choice === "overwrite") { + await writeFile(options.path, options.skeleton, "utf8"); + return { created: false, migration: "overwrite" }; + } + + // append — add the include at the end, leaving existing content untouched. + const appended = existing.endsWith("\n") + ? `${existing}\n${options.includeLine}\n` + : `${existing}\n\n${options.includeLine}\n`; + await writeFile(options.path, appended, "utf8"); + return { created: false, migration: "append" }; +} + +function referencesIncludeLine(content: string, includeLine: string): boolean { + // Match only when the include sits on a line by itself (allowing leading + // and trailing whitespace). Earlier we split on `\s+`, but that + // false-positives on commented-out includes like `` + // where the middle token equals the include. CRLF is handled by the + // `\r?\n` split. + for (const line of content.split(/\r?\n/)) { + if (line.trim() === includeLine) { + return true; + } + } + return false; +} + function buildSkillsReference( skills: Pick[] ): string { return skills - .map((skill) => `- \`.claude/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`) + .map( + (skill) => + `- \`.agents/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}` + ) .join("\n"); } @@ -89,7 +237,9 @@ async function copySkillsFromSource( ): Promise { const skillsDirs = await ensureSkillsDirectories(targetDir); await Promise.all( - skillsDirs.map((skillsDir) => copyDirectoryContents(skillsSourcePath, skillsDir)) + skillsDirs.map((skillsDir) => + copyDirectoryContents(skillsSourcePath, skillsDir) + ) ); return await readSkillMetadataFromDirectory(skillsDirs[0]); } @@ -137,7 +287,10 @@ async function ensureSkillsDirectories(targetDir: string): Promise { return skillsDirs; } -async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise { +async function copyDirectoryContents( + sourceDir: string, + targetDir: string +): Promise { const entries = await readdir(sourceDir, { withFileTypes: true }); await Promise.all( @@ -150,7 +303,10 @@ async function copyDirectoryContents(sourceDir: string, targetDir: string): Prom ); } -function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean): string { +function renderGeneratedSkillContent( + skillName: string, + nonDottedPaths: boolean +): string { let skillContent = SKILL_CONTENT[skillName]; if (!skillContent) { throw new Error(`Missing generated skill content for ${skillName}`); @@ -187,7 +343,11 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean) if (!schemaYaml) { return null; } - return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern); + return formatSchemaForMarkdown( + schemaYaml, + mapping.name, + mapping.filePattern + ); }) .filter((entry): entry is string => entry !== null); @@ -198,11 +358,15 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean) return `${skillContent}\n\n${schemaDocs.join("\n\n")}`; } -async function readSkillMetadataFromDirectory(skillsDir: string): Promise { +async function readSkillMetadataFromDirectory( + skillsDir: string +): Promise { const entries = await readdir(skillsDir, { withFileTypes: true }); const skills: ResolvedSkillMetadata[] = []; - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + for (const entry of entries.sort((left, right) => + left.name.localeCompare(right.name) + )) { if (!entry.isDirectory()) { continue; } @@ -219,7 +383,10 @@ async function readSkillMetadataFromDirectory(skillsDir: string): Promise { - if (!options.overwrite && (await stat(options.targetPath).catch(() => null))) { - return false; - } - - await writeFile(options.targetPath, options.content, "utf8"); - return true; -} - function formatSchemaForMarkdown( schemaYaml: string, schemaName: string, diff --git a/cli/src/main.ts b/cli/src/main.ts index dad66d351f..e807779ed9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -40,6 +40,8 @@ import workers from "./commands/workers/workers.ts"; import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; +import refresh from "./commands/refresh/refresh.ts"; +import { shouldRunFreshnessCheck } from "./guidance/freshness_gate.ts"; import jobs from "./commands/jobs/jobs.ts"; import job from "./commands/job/job.ts"; import group from "./commands/group/group.ts"; @@ -85,7 +87,7 @@ export { token, }; -export const VERSION = "1.705.0"; +export const VERSION = "1.706.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; @@ -175,6 +177,7 @@ const command = new Command() }, }) .command("init", init) + .command("refresh", refresh) .command("app", app) .command("flow", flow) .command("script", script) @@ -291,6 +294,15 @@ async function main() { await detectAuthGatewayChallenge(response); return response; }); + + // Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle. + // The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts + // bundle stays out of the import graph for help/version/init/refresh/etc. + if (shouldRunFreshnessCheck(process.argv)) { + const { warnIfPromptsStale } = await import("./guidance/freshness.ts"); + await warnIfPromptsStale({ argv: process.argv }).catch(() => {}); + } + await command.parse(args); } catch (e) { if (e && typeof e === "object" && "name" in e && e.name === "ApiError") { diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index 4399639905..fa3a91d641 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -3,8 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeAiGuidanceFiles } from "../src/guidance/writer.ts"; +import { + currentPromptsHash, + extractPromptsHash, + injectPromptsHashMarker, + shouldRunFreshnessCheck, + warnIfPromptsStale, +} from "../src/guidance/freshness.ts"; -const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const; +const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const; async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_")); @@ -26,7 +33,7 @@ async function writeSkill( return skillPath; } -describe("writeAiGuidanceFiles", () => { +describe("writeAiGuidanceFiles — skills", () => { test("preserves custom skills when refreshing generated guidance", async () => { await withTempDir(async (tempDir) => { const skillsDirs = SKILL_TARGET_ROOTS.map((root) => @@ -52,19 +59,21 @@ Preserve me. ) ); - await writeAiGuidanceFiles({ - targetDir: tempDir, - overwriteProjectGuidance: false, - }); + await writeAiGuidanceFiles({ targetDir: tempDir }); + // Custom skills survive on every side, untouched. for (const customSkillPath of customSkillPaths) { expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent); } + // Both `.agents/skills/` and `.claude/skills/` hold the same full + // canonical content. (Claude's skill loader doesn't expand `@` + // references inside SKILL.md, so we can't dedupe via `@`-include.) for (const generatedSkillPath of generatedSkillPaths) { const generatedSkillContent = await readFile(generatedSkillPath, "utf8"); expect(generatedSkillContent).not.toBe(staleGeneratedContent); expect(generatedSkillContent).toContain("name: write-flow"); + expect(generatedSkillContent).not.toContain("@../../../"); } }); }); @@ -101,7 +110,7 @@ Copied from source bundle. writeSkill(skillsDir, "custom-skill", customSkillContent) ) ); - const existingGeneratedSkillPaths = await Promise.all( + await Promise.all( skillsDirs.map((skillsDir) => writeSkill(skillsDir, "write-flow", "old content") ) @@ -113,25 +122,28 @@ Copied from source bundle. await writeAiGuidanceFiles({ targetDir: tempDir, - overwriteProjectGuidance: false, skillsSourcePath: sourceSkillsDir, }); + // Custom skills survive untouched on every side. for (const customSkillPath of customSkillPaths) { expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent); } - for (const existingGeneratedSkillPath of existingGeneratedSkillPaths) { - expect(await readFile(existingGeneratedSkillPath, "utf8")).toBe(sourceSkillContent); - } - for (const skillsDir of skillsDirs) { - expect(await readFile(join(skillsDir, "bundle-only", "SKILL.md"), "utf8")).toBe( - bundleOnlySkillContent - ); + + // Source bundle is copied verbatim into both `.agents/skills/` and + // `.claude/skills/`. No `@`-include wrapping. + for (const root of SKILL_TARGET_ROOTS) { + expect( + await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8") + ).toBe(sourceSkillContent); + expect( + await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8") + ).toBe(bundleOnlySkillContent); } }); }); - test("builds AGENTS skill references from copied directory names", async () => { + test("AGENTS.cli.md gets the skills reference from copied directory names", async () => { await withTempDir(async (tempDir) => { const sourceSkillsDir = join(tempDir, "source-skills"); await writeSkill( @@ -148,29 +160,393 @@ Copied from source bundle. await writeAiGuidanceFiles({ targetDir: tempDir, - overwriteProjectGuidance: false, skillsSourcePath: sourceSkillsDir, }); - const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); - expect(agentsMd).toContain(".claude/skills/custom-folder/SKILL.md"); - expect(agentsMd).not.toContain(".claude/skills/write-flow/SKILL.md"); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md"); + expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md"); + // The skill reference points at the .agents/ tree — not .claude/ — + // so the path is meaningful to Codex/Pi as well as Claude. + expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md"); }); }); - test("writes AGENTS.md and CLAUDE.md even if skills creation fails", async () => { + test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => { await withTempDir(async (tempDir) => { + // Create a file at .claude so mkdir of .claude/skills throws. await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8"); await expect( - writeAiGuidanceFiles({ - targetDir: tempDir, - overwriteProjectGuidance: false, - }) + writeAiGuidanceFiles({ targetDir: tempDir }) ).rejects.toThrow(); - expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(".claude/skills/"); - expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain("@AGENTS.md"); + expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain( + ".agents/skills/" + ); + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain( + "@AGENTS.cli.md" + ); + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain( + "@AGENTS.md" + ); + }); + }); +}); + +describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { + test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => { + await withTempDir(async (tempDir) => { + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsCreated).toBe(true); + expect(result.agentsMigration).toBe("not-applicable"); + + const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(agentsMd).toContain("@AGENTS.cli.md"); + }); + }); + + test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => { + await withTempDir(async (tempDir) => { + const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("already-linked"); + + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original); + }); + }); + + test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Existing custom AGENTS.md\n\nproject rules here.\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); + + test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Some old AGENTS.md to be replaced\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "overwrite", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("overwrite"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).not.toBe(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); + + test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Hand-managed AGENTS.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "skip", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("skip"); + + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original); + }); + }); + + test("defaults to 'append' when the resolver is not provided", async () => { + await withTempDir(async (tempDir) => { + const original = "# AGENTS.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); +}); + +describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => { + test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => { + await withTempDir(async (tempDir) => { + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.claudeCreated).toBe(true); + expect(result.claudeMigration).toBe("not-applicable"); + + const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8"); + expect(claudeMd).toContain("@AGENTS.md"); + }); + }); + + test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => { + await withTempDir(async (tempDir) => { + const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("already-linked"); + + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original); + }); + }); + + test("appends @AGENTS.md when the resolver returns 'append'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.md"); + }); + }); + + test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Some old CLAUDE.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "overwrite", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("overwrite"); + + expect( + await readFile(join(tempDir, "CLAUDE.md"), "utf8") + ).not.toBe(original); + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain( + "@AGENTS.md" + ); + }); + }); + + test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Hand-managed CLAUDE.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "skip", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("skip"); + + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original); + }); + }); + + test("resolver is invoked at most once even if both files need it", async () => { + await withTempDir(async (tempDir) => { + const original = "# old\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + let resolverCalls = 0; + await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => { + resolverCalls += 1; + return "append"; + }, + }); + expect(resolverCalls).toBe(1); + }); + }); +}); + +describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => { + test.each([ + ["bare line", "@AGENTS.cli.md"], + ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], + ["leading whitespace then include", " @AGENTS.cli.md\n"], + ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + ])("treats %s as a reference (no append)", async (_label, content) => { + await withTempDir(async (tempDir) => { + await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsMigration).toBe("already-linked"); + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content); + }); + }); + + test.each([ + ["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"], + ["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"], + ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], + ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], + ["commented-out include", ""], + ["blockquoted include", "> @AGENTS.cli.md"], + ])("does not treat %s as a reference (append happens)", async (_label, content) => { + await withTempDir(async (tempDir) => { + await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.agentsMigration).toBe("append"); + }); + }); +}); + +describe("prompts freshness — hash marker", () => { + test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => { + await withTempDir(async (tempDir) => { + await writeAiGuidanceFiles({ targetDir: tempDir }); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + const hash = extractPromptsHash(agentsCli); + expect(hash).not.toBeNull(); + expect(hash).toMatch(/^[0-9a-f]{12}$/); + }); + }); + + test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => { + await withTempDir(async (tempDir) => { + // writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching + // core/conf.ts's missing-key default). + await writeAiGuidanceFiles({ targetDir: tempDir }); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false)); + }); + }); + + test("nonDottedPaths setting changes the hash", () => { + expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false)); + }); + + test("injectPromptsHashMarker places the marker after the H1 title", () => { + const input = "# Title\n\nbody line\n"; + const out = injectPromptsHashMarker(input, "abc123def456"); + const lines = out.split("\n"); + expect(lines[0]).toBe("# Title"); + expect(lines[1]).toBe(""); + expect(lines[2]).toBe(""); + expect(lines[3]).toBe("body line"); + }); + + test("injectPromptsHashMarker prepends when there's no H1", () => { + const input = "no heading\nrest\n"; + const out = injectPromptsHashMarker(input, "abc123def456"); + expect(out).toStartWith(""); + }); + + test("extractPromptsHash returns null when no marker is present", () => { + expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull(); + expect(extractPromptsHash("")).toBeNull(); + }); +}); + +describe("prompts freshness — shouldRunFreshnessCheck", () => { + // Each input matches process.argv shape: [node, script, ...args]. + test.each<[string, string[], boolean]>([ + ["empty argv", ["node", "wmill"], false], + ["wmill --help", ["node", "wmill", "--help"], false], + ["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false], + ["wmill --version", ["node", "wmill", "--version"], false], + ["wmill init", ["node", "wmill", "init"], false], + ["wmill init prompts", ["node", "wmill", "init", "prompts"], false], + ["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false], + ["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false], + ["wmill upgrade", ["node", "wmill", "upgrade"], false], + ["wmill sync push", ["node", "wmill", "sync", "push"], true], + ["wmill flow run", ["node", "wmill", "flow", "run"], true], + ["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true], + // Value-taking global options must skip their value when locating the + // first subcommand. Otherwise `wmill --workspace prod refresh prompts` + // would misread `"prod"` as the subcommand and trip the warning during + // the very command that's meant to fix it. + ["wmill --workspace prod refresh prompts", + ["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false], + ["wmill --token tok sync push", + ["node", "wmill", "--token", "tok", "sync", "push"], true], + ["wmill --base-url u --workspace w init", + ["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false], + ["wmill --config-dir /etc/wmill init prompts", + ["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false], + ])("%s → %s", (_label, argv, expected) => { + expect(shouldRunFreshnessCheck(argv)).toBe(expected); + }); +}); + +describe("prompts freshness — additional invariants", () => { + test("currentPromptsHash is deterministic across invocations in the same process", () => { + const h1 = currentPromptsHash(true); + const h2 = currentPromptsHash(true); + const h3 = currentPromptsHash(false); + const h4 = currentPromptsHash(false); + expect(h1).toBe(h2); + expect(h3).toBe(h4); + }); + + test("warnIfPromptsStale writes to stderr (never stdout)", async () => { + await withTempDir(async (tempDir) => { + // Write a tampered AGENTS.cli.md so the freshness check trips. + await writeFile( + join(tempDir, "AGENTS.cli.md"), + "# Windmill CLI Agent Instructions\n\nbody\n", + "utf8" + ); + + const stdoutWrites: string[] = []; + const stderrWrites: string[] = []; + const originalStdout = process.stdout.write.bind(process.stdout); + const originalStderr = process.stderr.write.bind(process.stderr); + // @ts-expect-error — overriding write for the test + process.stdout.write = (chunk: any) => { + stdoutWrites.push(String(chunk)); + return true; + }; + // @ts-expect-error — overriding write for the test + process.stderr.write = (chunk: any) => { + stderrWrites.push(String(chunk)); + return true; + }; + + try { + await warnIfPromptsStale({ + cwd: tempDir, + nonDottedPaths: false, + argv: ["node", "wmill", "sync", "push"], + }); + } finally { + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + } + + const stderrJoined = stderrWrites.join(""); + const stdoutJoined = stdoutWrites.join(""); + expect(stderrJoined).toContain("out of date"); + expect(stdoutJoined).not.toContain("out of date"); }); }); }); diff --git a/debugger/Dockerfile b/debugger/Dockerfile index fe2b769e7c..51e993746a 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -51,6 +51,14 @@ COPY dap_websocket_server.py . # Expose the default port EXPOSE 5679 +# Create a non-root user 'windmill' with UID and GID 1000 (mirrors main Windmill image) +RUN addgroup --gid 1000 windmill && \ + adduser --disabled-password --gecos "" --uid 1000 --gid 1000 windmill + +# Ensure cache and work directories are writable by any UID +RUN mkdir -p /tmp/windmill/cache /tmp/windmill/cache_nomount /tmp/.cache && \ + chmod -R 777 /tmp/windmill /tmp/.cache /app + # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://localhost:5679/health || exit 1 diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 181d87b380..2bfa883466 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index cc74d14996..d6616b5b97 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4952b96d14..442771f584 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7bfde58794..75d3a2d30f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index dff8591322..834ef0b96c 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -72,6 +72,20 @@ return false } } + // Hide the nsjail-only settings only when isolation is *explicitly* a + // non-nsjail mode. When `job_isolation` is unset, nsjail may still be + // enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so + // keep the controls reachable. + if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') { + const isolation = values['job_isolation'] + if (isolation === 'none' || isolation === 'unshare') { + return false + } + } + // The tmpfs size knob is meaningless when /tmp is disk-backed. + if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') { + return false + } return true } diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 5967a75d30..e689c3e6c0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -4,7 +4,10 @@ import AvailableContextList from './AvailableContextList.svelte' import { type Snippet } from 'svelte' import { + AlertTriangle, ArrowDown, + ChevronDown, + ChevronsRight, CheckIcon, HistoryIcon, Hourglass, @@ -23,16 +26,43 @@ import ProviderModelSelector from './ProviderModelSelector.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' - import { AIMode } from './AIChatManager.svelte' + import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' import type { SelectedContext } from './app/core' + const MAX_YOLO_TOOLTIP_TOOLS = 8 const aiChatManager = getAiChatManager() + type AutonomyModeOption = { label: string; mode: AIAutonomyMode } + const autonomyModeOptions: AutonomyModeOption[] = [ + { label: 'auto accept off', mode: AIAutonomyMode.DEFAULT }, + { label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT }, + { label: 'yolo on', mode: AIAutonomyMode.YOLO } + ] + const autonomyModeLabel = ( + mode: AIAutonomyMode, + options: AutonomyModeOption[] = autonomyModeOptions + ) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label + const isAutonomyModeAvailable = ( + mode: AIAutonomyMode, + autoAcceptEditsAvailable: boolean, + autoAcceptToolConfirmationsAvailable: boolean + ) => { + switch (mode) { + case AIAutonomyMode.DEFAULT: + return true + case AIAutonomyMode.ACCEPT_EDIT: + return autoAcceptEditsAvailable + case AIAutonomyMode.YOLO: + return autoAcceptToolConfirmationsAvailable + } + return false + } let { messages, @@ -179,6 +209,37 @@ aiChatManager.mode === AIMode.GLOBAL || aiChatManager.mode === AIMode.APP ) + const availableAutonomyModeOptions = $derived.by(() => + autonomyModeOptions.filter((option) => + isAutonomyModeAvailable( + option.mode, + aiChatManager.autoAcceptEditsAvailable, + aiChatManager.autoAcceptToolConfirmationsAvailable + ) + ) + ) + const effectiveAutonomyMode = $derived( + availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) + ? aiChatManager.autonomyMode + : AIAutonomyMode.DEFAULT + ) + const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) + const autonomyModeTooltip = $derived.by(() => { + switch (effectiveAutonomyMode) { + case AIAutonomyMode.ACCEPT_EDIT: + return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.' + case AIAutonomyMode.YOLO: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Automatically accepts tool confirmations.' + } + return 'Automatically accepts script and flow edits plus tool confirmations.' + default: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Requires confirmation for tool calls.' + } + return 'Requires confirmation for edits and tool calls.' + } + }) // "Waiting for user" detection — when the latest tool message is staged // for confirmation or has an unanswered askUserQuestion, the AI loop is @@ -209,6 +270,29 @@ } return aiChatManager.appAiChatHelpers.getSelectedContext() }) + + const yoloBypassedTools = $derived.by(() => { + return aiChatManager.tools + .filter((tool) => tool.requiresConfirmation === true) + .map((tool) => ({ + name: tool.def.function.name, + label: tool.confirmationMessage ?? tool.def.function.name + })) + }) + const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS)) + const hiddenYoloBypassedToolCount = $derived( + Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length) + ) + const showFlowPendingActionControls = $derived( + (aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) && + !aiChatManager.autoAcceptEditsActive + ) + const showFooterLeftControls = $derived( + !disabled && + (showContextPicker || + showAutonomyModeSelector || + (aiChatManager.mode === AIMode.SCRIPT && hasDiff)) + )
@@ -322,7 +406,7 @@
{#if waitingForUserAction} @@ -345,7 +429,7 @@ transition:fade={{ duration: 120 }} class={twMerge( 'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md', - aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2' + showFlowPendingActionControls ? 'bottom-12' : 'bottom-2' )} > + {/each} +
+ {/snippet} + +
+ {/if} + {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable} + + + {#snippet text()} +
+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'Yolo auto-accepts edits and tool usage.' + : 'Yolo auto-accepts tool usage.'} +

+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'This can result in edits being applied or tools being called without user confirmation.' + : 'This can result in tools being called without user confirmation.'} +

+ {#if yoloBypassedTools.length > 0} +

Bypassed in current mode:

+
    + {#each visibleYoloBypassedTools as tool (tool.name)} +
  • {tool.label}
  • + {/each} +
+ {#if hiddenYoloBypassedToolCount > 0} +

+ {hiddenYoloBypassedToolCount} more

+ {/if} + {:else} +

No tools in the current mode require confirmation.

+ {/if} +
+ {/snippet} +
+ {/if} + {#if aiChatManager.mode === AIMode.SCRIPT && hasDiff} + + {/if} + + {/if} {#if disabled}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte index 8e57dde1fe..da1a1e26af 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte @@ -213,7 +213,7 @@ try { const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection) if (reply) { - aiChatManager.scriptEditorApplyCode?.(reply) + await aiChatManager.applyScriptEditorCode(reply) } } catch (error) { console.error('Inline AI request failed:', error) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b56edcc43..93145e3b1b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' import { get } from 'svelte/store' +import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' @@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05 const MAX_TOKENS_HARD_LIMIT = 5000 +const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' +const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' export enum AIMode { SCRIPT = 'script', @@ -77,12 +80,38 @@ export enum AIMode { ASK = 'ask' } +export enum AIAutonomyMode { + DEFAULT = 'default', + ACCEPT_EDIT = 'acceptedit', + YOLO = 'yolo' +} + const ALL_AI_MODES = Object.values(AIMode) +const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode) +const AUTO_ACCEPT_EDIT_MODES = new Set([AIMode.SCRIPT, AIMode.FLOW]) +const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.APP, + AIMode.GLOBAL +]) export function isAIMode(mode: unknown): mode is AIMode { return ALL_AI_MODES.includes(mode as AIMode) } +export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode { + return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode) +} + +export function supportsAutoAcceptEdits(mode: AIMode): boolean { + return AUTO_ACCEPT_EDIT_MODES.has(mode) +} + +export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean { + return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode) +} + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string { return path?.startsWith('f/') === true || path?.startsWith('u/') === true } +function getPersistedAutonomyMode(): AIAutonomyMode { + if (!BROWSER || typeof localStorage === 'undefined') { + return AIAutonomyMode.DEFAULT + } + const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY) + if (isAIAutonomyMode(persistedMode)) { + return persistedMode + } + return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true' + ? AIAutonomyMode.YOLO + : AIAutonomyMode.DEFAULT +} + +function persistAutonomyMode(mode: AIAutonomyMode) { + if (!BROWSER || typeof localStorage === 'undefined') { + return + } + localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode) +} + export class AIChatManager { contextManager = new ContextManager() historyManager = new HistoryManager() @@ -112,6 +161,17 @@ export class AIChatManager { currentReply = $state('') displayMessages = $state([]) messages = $state([]) + autonomyMode = $state(getPersistedAutonomyMode()) + autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) + autoAcceptEditsActive = $derived( + this.autoAcceptEditsAvailable && + (this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT || + this.autonomyMode === AIAutonomyMode.YOLO) + ) + autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode)) + autoAcceptToolConfirmationsActive = $derived( + this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable + ) #automaticScroll = $state(true) systemMessage = $state({ role: 'system', @@ -122,9 +182,9 @@ export class AIChatManager { scriptEditorOptions = $state(undefined) flowOptions = $state(undefined) - scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>( - undefined - ) + scriptEditorApplyCode = $state< + ((code: string, opts?: ReviewChangesOpts) => void | Promise) | undefined + >(undefined) scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined) scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined) flowAiChatHelpers = $state(undefined) @@ -141,7 +201,7 @@ export class AIChatManager { /** Cached datatables for app context (fetched asynchronously) */ cachedDatatables = $state([]) - private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private confirmationCallbacks = new Map void>() private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined @@ -215,20 +275,65 @@ export class AIChatManager { // Request confirmation from user for a tool call requestConfirmation = (toolId: string): Promise => { + if (this.autoAcceptToolConfirmationsActive) { + return Promise.resolve(true) + } + return new Promise((resolve) => { - // Store the callback for this specific tool - this.confirmationCallback = resolve + this.confirmationCallbacks.set(toolId, resolve) }) } // Handle confirmation response for a specific tool handleToolConfirmation = (toolId: string, confirmed: boolean) => { - if (this.confirmationCallback) { - this.confirmationCallback(confirmed) - this.confirmationCallback = undefined + const confirmationCallback = this.confirmationCallbacks.get(toolId) + if (confirmationCallback) { + confirmationCallback(confirmed) + this.confirmationCallbacks.delete(toolId) } } + private acceptPendingToolConfirmations = () => { + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(true) + } + this.confirmationCallbacks.clear() + } + + private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => { + if (flowHelpers?.hasPendingChanges()) { + flowHelpers.acceptAllModuleActions() + } + } + + setAutonomyMode = (mode: AIAutonomyMode) => { + this.autonomyMode = mode + persistAutonomyMode(mode) + + if (this.autoAcceptToolConfirmationsActive) { + this.acceptPendingToolConfirmations() + } + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } + } + + setAutoAcceptToolConfirmations = (enabled: boolean) => { + this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT) + } + + applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => { + if (this.autoAcceptEditsActive && opts?.mode === 'revert') { + return + } + + const effectiveOpts = + this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply' + ? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts) + : opts + await this.scriptEditorApplyCode?.(code, effectiveOpts) + } + requestUserQuestion = ( toolId: string, _question: { question: string; choices: string[] } @@ -346,7 +451,7 @@ export class AIChatManager { }, getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget, applyCode: (code: string, opts?: ReviewChangesOpts) => { - this.scriptEditorApplyCode?.(code, opts) + return this.applyScriptEditorCode(code, opts) }, getLintErrors: () => { if (this.scriptEditorGetLintErrors) { @@ -874,6 +979,7 @@ export class AIChatManager { } }, requestConfirmation: this.requestConfirmation, + shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive, requestUserQuestion: this.requestUserQuestion } } @@ -886,6 +992,9 @@ export class AIChatManager { ...params }) this.messages = [...this.messages, ...(addedMessages ?? [])] + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } await this.historyManager.saveChat(this.displayMessages, this.messages) } catch (err) { console.error(err) @@ -901,10 +1010,10 @@ export class AIChatManager { } cancel = (reason?: string) => { - if (this.confirmationCallback) { - this.confirmationCallback(false) - this.confirmationCallback = undefined + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(false) } + this.confirmationCallbacks.clear() for (const resolveQuestion of this.userQuestionCallbacks.values()) { resolveQuestion(undefined) } @@ -1060,10 +1169,10 @@ export class AIChatManager { listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => { if (currentEditor && currentEditor.type === 'script') { - this.scriptEditorApplyCode = (code) => { + this.scriptEditorApplyCode = async (code, opts) => { if (currentEditor && currentEditor.type === 'script') { currentEditor.hideDiffMode() - currentEditor.editor.reviewAndApplyCode(code) + await currentEditor.editor.reviewAndApplyCode(code, opts) } } this.scriptEditorShowDiffMode = () => { @@ -1164,6 +1273,11 @@ export class AIChatManager { setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => { this.flowAiChatHelpers = flowHelpers + untrack(() => { + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits(flowHelpers) + } + }) return () => { this.flowAiChatHelpers = undefined diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts new file mode 100644 index 0000000000..df2cba5575 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FlowAIChatHelpers } from './flow/core' +import type { CurrentEditor } from '$lib/components/flows/types' +import type { ReviewChangesOpts } from './monaco-adapter' +import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' + +vi.mock('monaco-editor', () => ({ + Selection: class Selection {} +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: {}, + ScriptService: {}, + FlowService: {}, + JobService: {} +})) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => undefined } +})) + +vi.mock('$lib/toast', () => ({ + sendUserToast: vi.fn() +})) + +vi.mock('$lib/aiStore', () => ({ + getCurrentModel: () => undefined, + tryGetCurrentModel: () => undefined, + getCombinedCustomPrompt: () => '' +})) + +vi.mock('../lib', () => ({ + getModelContextWindow: () => 128000, + workspaceAIClients: { subscribe: () => () => undefined } +})) + +vi.mock('./api/apiTools', () => ({ + loadApiTools: vi.fn() +})) + +vi.mock('./chatLoop', () => ({ + runChatLoop: vi.fn() +})) + +vi.mock('./global/gate', () => ({ + isGlobalAiEnabled: () => true +})) + +function createFlowHelpers({ + hasPendingChanges, + acceptAllModuleActions +}: { + hasPendingChanges: () => boolean + acceptAllModuleActions: () => void +}): FlowAIChatHelpers { + return { + getFlowAndSelectedId: vi.fn(), + getRootModules: vi.fn(), + inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() }, + setSnapshot: vi.fn(), + revertToSnapshot: vi.fn(), + setCode: vi.fn(), + setFlowJson: vi.fn(), + getFlowInputsSchema: vi.fn(), + updateExprsToSet: vi.fn(), + acceptAllModuleActions, + rejectAllModuleActions: vi.fn(), + hasPendingChanges, + selectStep: vi.fn(), + testFlow: vi.fn(), + getLintErrors: vi.fn() + } as unknown as FlowAIChatHelpers +} + +describe('AIChatManager autonomy mode', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('accepts pending flow edits when auto-accept is enabled from script mode', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('waits for flow step editor review before resolving applyScriptEditorCode', async () => { + const manager = new AIChatManager() + let finishReview: (() => void) | undefined + const reviewPromise = new Promise((resolve) => { + finishReview = resolve + }) + const hideDiffMode = vi.fn() + const reviewAndApplyCode = vi.fn(() => reviewPromise) + const opts = { mode: 'apply' } satisfies ReviewChangesOpts + + manager.listenForCurrentEditorChanges({ + type: 'script', + stepId: 'step-a', + editor: { + reviewAndApplyCode, + getLintErrors: vi.fn() + }, + showDiffMode: vi.fn(), + hideDiffMode, + diffMode: false, + lastDeployedCode: undefined + } as unknown as CurrentEditor) + + let applied = false + const applyPromise = manager + .applyScriptEditorCode('export async function main() {}', opts) + .then(() => { + applied = true + }) + + await Promise.resolve() + + expect(hideDiffMode).toHaveBeenCalledTimes(1) + expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts) + expect(applied).toBe(false) + + finishReview?.() + await applyPromise + + expect(applied).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index d8ca5858ea..9a12a04548 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index d26f5cbb54..69b868ee50 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -247,6 +247,49 @@ describe('processToolCall', () => { expect(result.content).toBe('ok') }) + it('auto-accepts required confirmations when yolo mode is active', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const fn = vi.fn().mockResolvedValue('ok') + const requestConfirmation = vi.fn() + const setToolStatus = vi.fn() + + const result = await processToolCall({ + tools: [ + { + def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'), + requiresConfirmation: true, + confirmationMessage: 'Create schedule', + fn + } + ], + toolCall: { + id: 'call_yolo', + type: 'function', + function: { name: 'create_schedule', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { + setToolStatus, + removeToolStatus: vi.fn(), + requestConfirmation, + shouldAutoAcceptToolConfirmations: () => true + } + }) + + expect(requestConfirmation).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalled() + expect(setToolStatus).toHaveBeenCalledWith( + 'call_yolo', + expect.objectContaining({ + content: 'Create schedule', + isLoading: true, + needsConfirmation: false + }) + ) + expect(result.content).toBe('ok') + }) + it('blocks workspace mutation tools for undeployed scripts and flows', async () => { const { processToolCall } = await import('./shared') const { createWorkspaceMutationTools } = await import('./workspaceTools') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 0723cc3eb3..88ed20d664 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -582,10 +582,13 @@ export async function processToolCall({ } // Check if tool requires confirmation - const needsConfirmation = tool?.requiresConfirmation + const requiresConfirmation = tool?.requiresConfirmation === true + const autoAcceptConfirmation = + requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true + const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation toolCallbacks.setToolStatus(toolCall.id, { - ...(tool?.requiresConfirmation + ...(requiresConfirmation ? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' } : {}), parameters: args, @@ -695,6 +698,7 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + shouldAutoAcceptToolConfirmations?: () => boolean requestUserQuestion?: ( toolId: string, question: UserQuestionDisplay diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index d8cf5901ec..a127027dac 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -245,11 +245,25 @@ export const settings: Record = { } ] }, + { + label: 'Nsjail /tmp backing', + key: 'nsjail_tmp_backing', + fieldType: 'select', + description: + 'How /tmp is backed inside the nsjail sandbox. RAM (tmpfs) is the default — fast, with a hard size cap from Nsjail tmpfs size, but consumes worker memory. Disk (bind mount) uses a per-job directory on the worker disk — no RAM cost, but the only remaining per-file ceiling is rlimit_fsize (~1GB for python/ansible, unbounded for most other languages because they set disable_rl: true); pair with host disk monitoring or quotas.', + storage: 'setting', + placeholder: 'tmpfs', + defaultValue: () => 'tmpfs', + select_items: [ + { label: 'RAM (tmpfs) — default', value: 'tmpfs' }, + { label: 'Disk (bind mount)', value: 'disk' } + ] + }, { label: 'Nsjail tmpfs size (MB)', key: 'nsjail_tmpfs_size_mb', description: - 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when the job isolation mode is set to Nsjail.', + 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when Nsjail /tmp backing is RAM (tmpfs).', fieldType: 'number', placeholder: '800', storage: 'setting' diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 0be40c0bca..902a90c9d1 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,41 +1,11 @@ { - "deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", - "deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", - "deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", - "deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", - "deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", - "deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", - "deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", - "deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", - "deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", - "deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", - "deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", - "deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", - "deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", - "deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", - "deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", - "deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", - "deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", - "deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", - "deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", - "deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill", - "deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill", - "deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill", - "deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", "gitInitRepo": "hub/28219/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack", "slackSuccessHandler": "hub/28220/slack/schedule-success-handler-slack", "teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams", - "teamsErrorHandler_0": "hub/11598/workspace-or-schedule-error-handler-teams", "teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams", "teamsSuccessHandler": "hub/11596/schedule-success-handler-teams", "slackReport": "hub/9084/slack", diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index e3a927281a..aad16431cb 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -601,7 +601,7 @@ export function useLocalStorageValue( */ transformBeforePersist?: (val: T) => T } -): { val: T; skipNextWriteOnce(): void } { +): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 const transformBeforePersist = options?.transformBeforePersist @@ -626,7 +626,9 @@ export function useLocalStorageValue( } } - if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} } + if (typeof window === 'undefined') { + return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} } + } const savedValue = localStorage.getItem(key) let s = $state( savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue @@ -662,6 +664,13 @@ export function useLocalStorageValue( pendingValue = undefined }, debounceMs) } + const cancelPendingWrite = () => { + if (debounceTimer != null) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + pendingValue = undefined + } $effect(() => { readFieldsRecursively(s) @@ -698,12 +707,19 @@ export function useLocalStorageValue( /** * Arm the persist skip so the next `set val` (or deep-mutation flush) * updates only the in-memory cell and leaves localStorage untouched. - * Used by `UserDraft.discard` to reset the in-memory state to a - * fallback without re-persisting it — pairs with an explicit LS - * delete to leave the slot empty. */ skipNextWriteOnce(): void { skipNextWrite = true + }, + /** + * Reset the in-memory state while canceling any queued debounced write. + * Used when a caller performs its own synchronous persistence action. + */ + setWithoutPersist(newVal: T): void { + cancelPendingWrite() + lastSerialized = newVal === undefined ? undefined : serialize(newVal) + skipNextWrite = false + s = newVal } } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 923b87fec5..3d83952513 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' -export type UserDraftItemKind = - | 'script' - | 'flow' - | 'app' - | 'raw_app' - | 'resource' - | 'variable' - | 'trigger_schedule' - | 'trigger_webhook' - | 'trigger_default_email' - | 'trigger_email' - | 'trigger_http' - | 'trigger_websocket' - | 'trigger_postgres' - | 'trigger_kafka' - | 'trigger_nats' - | 'trigger_mqtt' - | 'trigger_sqs' - | 'trigger_gcp' - | 'trigger_azure' - | 'trigger_poll' - | 'trigger_cli' - | 'trigger_nextcloud' - | 'trigger_google' - | 'trigger_github' +export const USER_DRAFT_ITEM_KINDS = [ + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +] as const + +export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number] export type UserDraftOptions = { workspace?: string @@ -43,6 +46,10 @@ export type UserDraftUseOptions = UserDraftOptions & { defaultValue?: V } +export type UserDraftListOptions = UserDraftOptions & { + itemKinds?: readonly UserDraftItemKind[] +} + /** * A single (kind, path, workspace) tuple that `useMany` should hold a handle * for. The shape mirrors `use()`'s arguments, just bundled into one object @@ -93,10 +100,14 @@ function stamp(stored: StoredDraft | undefined): StoredDraft | undefine type DraftState = { val: StoredDraft | undefined skipNextWriteOnce(): void + setWithoutPersist(newVal: StoredDraft | undefined): void } type DraftEntry = { count: number + workspace: string + itemKind: UserDraftItemKind + path: string state: DraftState /** * Tears down the `$effect.root` scope that owns the entry's @@ -109,6 +120,16 @@ type DraftEntry = { destroyRoot?: () => void } +export type UserDraftEntry = { + workspace: string + itemKind: UserDraftItemKind + path: string + value: V | undefined + meta: UserDraftMeta + persisted: boolean + live: boolean +} + const entries = new Map() function resolveWorkspace(opts?: UserDraftOptions): string { @@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s return `userdraft/w/${workspace}/${itemKind}/${path}` } +function parseLocalStorageKey( + key: string, + workspace: string, + itemKinds: readonly UserDraftItemKind[] +): { itemKind: UserDraftItemKind; path: string } | undefined { + const prefix = `userdraft/w/${workspace}/` + if (!key.startsWith(prefix)) return undefined + const rest = key.slice(prefix.length) + for (const itemKind of itemKinds) { + const kindPrefix = `${itemKind}/` + if (rest.startsWith(kindPrefix)) { + return { itemKind, path: rest.slice(kindPrefix.length) } + } + } + return undefined +} + +function snapshotDraftValue(value: V | undefined): V | undefined { + if (value === undefined) return undefined + try { + return structuredClone($state.snapshot(value)) as V + } catch { + try { + return JSON.parse(JSON.stringify(value)) as V + } catch { + return undefined + } + } +} + export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) @@ -299,6 +350,27 @@ export const UserDraft = { } }, + setDraftAndMeta( + itemKind: UserDraftItemKind, + path: string, + value: V | undefined, + meta: UserDraftMeta, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + entry.state.val = wrap(value, meta) + // Static writes represent explicit external draft mutations. A + // freshly acquired live entry may still have the initial-write skip + // armed, so force the storage slot to match the live value. + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + return + } + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + }, + /** * Autosave gate: persist `value` only when it differs (after * `normalizeForCompare`) from the `deployed` baseline; otherwise remove @@ -396,6 +468,61 @@ export const UserDraft = { } }, + clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + UserDraft.discard(itemKind, path, undefined, opts) + }, + + list(opts?: UserDraftListOptions): UserDraftEntry[] { + const ws = resolveWorkspace(opts) + const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS + const out = new Map>() + + if (typeof localStorage !== 'undefined') { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key) + } + for (const key of keys) { + const parsed = parseLocalStorageKey(key, ws, itemKinds) + if (!parsed) continue + const stored = readPersisted(key) + if (stored === undefined) continue + out.set(mapKey(ws, parsed.itemKind, parsed.path), { + workspace: ws, + itemKind: parsed.itemKind, + path: parsed.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: true, + live: false + }) + } + } + + for (const entry of entries.values()) { + if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue + const stored = untrack(() => entry.state.val as StoredDraft | undefined) + const mk = mapKey(entry.workspace, entry.itemKind, entry.path) + if (stored === undefined) { + out.delete(mk) + continue + } + const existing = out.get(mk) + out.set(mk, { + workspace: entry.workspace, + itemKind: entry.itemKind, + path: entry.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: existing?.persisted ?? false, + live: true + }) + } + + return Array.from(out.values()) + }, + /** * Like `remove`, but also resets any live handle's `draft` to * `fallback` in-memory (so reactive readers see it immediately) and @@ -412,10 +539,11 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - // Arm the skip before the cell write so the setter suppresses - // the persist; the removeItem below actually clears the slot. - entry.state.skipNextWriteOnce() - entry.state.val = wrap(fallback) as StoredDraft | undefined + // Drop any queued debounced write owned by this live entry before + // resetting the in-memory value. Otherwise a timer from the old + // entry can outlive unmount and later delete a freshly written + // draft for the same key. + entry.state.setWithoutPersist(wrap(fallback) as StoredDraft | undefined) } try { localStorage.removeItem(localStorageKey(ws, itemKind, path)) @@ -549,7 +677,7 @@ function acquireEntry( ) }) if (stateRef) { - entries.set(mk, { count: 1, state: stateRef, destroyRoot }) + entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot }) return } // Fallback for the vitest runtime where `$effect.root`'s callback isn't @@ -560,7 +688,7 @@ function acquireEntry( undefined, useLocalStorageOptions ) - entries.set(mk, { count: 1, state }) + entries.set(mk, { count: 1, workspace, itemKind, path, state }) } function releaseEntry(mk: string): void { diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index 90d25000e9..f703953f11 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => { expect(storedShape(KEY)).toBe(wrapped(value)) }) }) + +describe('UserDraft.list / clear / setDraftAndMeta', () => { + it('enumerates persisted-only drafts for the requested workspace and kinds', () => { + UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' }) + UserDraft.setDraftAndMeta( + 'flow', + 'f/b', + { path: 'f/b', value: { modules: [] } }, + { remoteRev: 2 }, + { workspace: 'other_ws' } + ) + UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {}) + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/a', + value: { path: 'f/a', content: 'a' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: false + } + ]) + expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([ + expect.objectContaining({ + workspace: 'other_ws', + itemKind: 'flow', + path: 'f/b', + persisted: true, + live: false + }) + ]) + }) + + it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => { + UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {}) + + const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] }) + + expect(entries).toHaveLength(3) + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + itemKind: 'script', + path: '', + value: { path: '', content: 'scratch' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-a', + value: { path: 'f/new-a', content: 'a' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-b', + value: { path: 'f/new-b', content: 'b' } + }) + ]) + ) + }) + + it('enumerates live-only drafts before the debounce persists them', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live') + handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' }) + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/live', + value: { path: 'f/live', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: false, + live: true + } + ]) + }) + + it('dedupes entries that are both persisted and live', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/both', + { path: 'f/both', content: 'persisted' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both') + handle.draft = { path: 'f/both', content: 'live' } + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/both', + value: { path: 'f/both', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: true + } + ]) + }) + + it('clear removes persisted storage and live state without re-persisting', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/clear', + { path: 'f/clear', content: 'x' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear') + expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' }) + + UserDraft.clear('script', 'f/clear') + flushPersist() + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('clear cancels pending debounced live writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear') + handle.draft = { path: 'f/pending-clear', content: 'initial' } + handle.draft = { path: 'f/pending-clear', content: 'pending' } + + UserDraft.clear('script', 'f/pending-clear') + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + }) + + it('clear does not let an old debounced remove delete a later direct write', () => { + const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear' + const handle = UserDraft.use<{ path: string; content: string }>( + 'script', + 'f/rewrite-after-clear' + ) + handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' } + handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' } + + UserDraft.clear('script', 'f/rewrite-after-clear') + flushDestroyCallbacks() + UserDraft.setDraftAndMeta( + 'script', + 'f/rewrite-after-clear', + { path: 'f/rewrite-after-clear', content: 'new' }, + {} + ) + + flushPersist() + expect(storedShape(key)).toBe( + wrapped({ path: 'f/rewrite-after-clear', content: 'new' }) + ) + }) + + it('list hides persisted drafts when a live handle has cleared the value', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/live-clear', + { path: 'f/live-clear', content: 'persisted' }, + {} + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear') + handle.draft = { path: 'f/live-clear', content: 'edited' } + handle.draft = undefined + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta') + + UserDraft.setDraftAndMeta( + 'script', + 'f/meta', + { path: 'f/meta', content: 'first' }, + { + remoteRev: 'h1', + remoteDraftRev: 'd1' + } + ) + handle.draft = { path: 'f/meta', content: 'second' } + + expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' }) + expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' }) + expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual( + expect.objectContaining({ + value: { path: 'f/meta', content: 'second' }, + meta: { remoteRev: 'h1', remoteDraftRev: 'd1' } + }) + ) + }) + + it('static setDraftAndMeta persists first writes even when a live handle exists', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live') + + UserDraft.setDraftAndMeta( + 'script', + 'f/static-live', + { path: 'f/static-live', content: 'first' }, + { remoteRev: 'h1' } + ) + + expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' }) + expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe( + JSON.stringify({ + value: { path: 'f/static-live', content: 'first' }, + remoteRev: 'h1' + }) + ) + }) + + it('lists live drafts with runtime-only values without throwing', () => { + const handle = UserDraft.use>('script', 'f/runtime') + handle.draft = { + path: 'f/runtime', + content: 'x', + callback: () => 'not serializable' + } + + expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + expect.objectContaining({ + itemKind: 'script', + path: 'f/runtime', + value: { path: 'f/runtime', content: 'x' } + }) + ]) + }) +}) diff --git a/lsp/Pipfile b/lsp/Pipfile index 238918d44c..2018887843 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.705.0" +wmill = ">=1.706.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9b406df385..c8594a6aed 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.705.0 + version: 1.706.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index b0683e959e..c23a7fbf3f 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.705.0' + ModuleVersion = '1.706.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 561c313aae..4f1ed0e32b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.705.0" +version = "1.706.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 9917afd696..342f4cade4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -387,6 +387,15 @@ List all queues with their metrics - `--instance [instance]` - Name of the instance to push to, override the active instance - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 35637f3a6c..3a0ee24ac8 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2918,6 +2918,15 @@ List all queues with their metrics - \`--instance [instance]\` - Name of the instance to push to, override the active instance - \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 0e4ac513c4..0ea6cf848f 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -392,6 +392,15 @@ List all queues with their metrics - `--instance [instance]` - Name of the instance to push to, override the active instance - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 12792da41d..e5a6d609ae 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1828,7 +1828,7 @@ CONTEXT7_REPO_NAME = "windmill-cli-docs" def extract_agents_md_template() -> str: - """Extract the AGENTS.md template string from cli/src/guidance/core.ts. + """Extract the AGENTS.cli.md template string from cli/src/guidance/core.ts. Keeping a single source of truth in TypeScript avoids drift between what `wmill init` writes locally and what we publish for context7 ingestion. @@ -1836,14 +1836,16 @@ def extract_agents_md_template() -> str: core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts" content = core_ts_path.read_text() # Anchor on the function name so adding other template-literal-returning - # functions to core.ts can't silently re-target the regex. + # functions to core.ts can't silently re-target the regex. The function + # was renamed from `generateAgentsMdContent` → `generateAgentsCliMdContent` + # when the managed file split out of AGENTS.md into AGENTS.cli.md. match = re.search( - r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", + r"function\s+generateAgentsCliMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", content, ) if not match: raise RuntimeError( - f"Could not extract AGENTS.md template from {core_ts_path}" + f"Could not extract AGENTS.cli.md template from {core_ts_path}" ) return _unescape_ts_template_literal(match.group(1)) @@ -1865,10 +1867,16 @@ def _unescape_ts_template_literal(raw: str) -> str: def render_agents_md_for_docs( skills: list[str], skill_desc_map: dict[str, str] ) -> str: - """Render AGENTS.md exactly as `wmill init` would, for the docs repo.""" + """Render AGENTS.cli.md exactly as `wmill init` would, for the docs repo. + + The skill reference paths point at `.agents/skills/` (the canonical tree + that Codex/Pi read directly and that Claude Code mirrors under + `.claude/skills/`) — matching `buildSkillsReference` in + `cli/src/guidance/writer.ts`. + """ template = extract_agents_md_template() skills_reference = "\n".join( - f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}" + f"- `.agents/skills/{name}/SKILL.md` - {skill_desc_map[name]}" for name in skills if name in skill_desc_map ) @@ -2002,7 +2010,10 @@ def generate_context7_repo( skill_desc_map = build_skill_desc_map(skills) - # AGENTS.md — the same content `wmill init` writes locally. + # AGENTS.md — the managed CLI guidance (what `wmill init` writes as + # AGENTS.cli.md locally). Kept under the `AGENTS.md` filename here to + # preserve the existing context7 ingest path; docs consumers read this + # as the canonical AGENTS file. (target_dir / "AGENTS.md").write_text( render_agents_md_for_docs(skills, skill_desc_map) ) diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 738580f772..ca7b1a859c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.705.0", + "version": "1.706.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4a85819712..51507ebdc7 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.705.0", + "version": "1.706.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 2ce80397aa..0fdf01b5f5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.705.0 +1.706.0