diff --git a/.github/scripts/check-docs-links.mjs b/.github/scripts/check-docs-links.mjs index 25647008b9..f03b956d9f 100644 --- a/.github/scripts/check-docs-links.mjs +++ b/.github/scripts/check-docs-links.mjs @@ -18,12 +18,7 @@ const RETRIES = 2 // Links whose target page is written but not yet deployed on windmill.dev: the app // link is already the final slug, so a 404 is expected until the docs side ships. // The value is why the entry exists, for whoever has to judge whether it still should. -const PENDING_DEPLOY = new Map([ - [ - 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt', - 'windmilldocs#1625 (dbt runtime quickstart)' - ] -]) +const PENDING_DEPLOY = new Map() async function walk(dir) { const out = [] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 72df8b2dec..2653a1e1de 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.780.0" + ".": "1.782.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f24836cb..58d0c3c376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## [1.782.0](https://github.com/windmill-labs/windmill/compare/v1.781.3...v1.782.0) (2026-08-06) + + +### Features + +* improve duckdb isolation ([#10565](https://github.com/windmill-labs/windmill/issues/10565)) ([956210e](https://github.com/windmill-labs/windmill/commit/956210ea06224f7c6cd3e5082a96d3f80039404b)) + + +### Bug Fixes + +* compact ai chat context for models with unknown context windows ([#10564](https://github.com/windmill-labs/windmill/issues/10564)) ([fc1e11c](https://github.com/windmill-labs/windmill/commit/fc1e11cb3d056af6a3779cf17b23f9bf7152d355)) +* keep the mermaid fullscreen dialog in its pane and its emoji vector ([#10541](https://github.com/windmill-labs/windmill/issues/10541)) ([c2a6936](https://github.com/windmill-labs/windmill/commit/c2a6936e7d3fe724f968b10dd689abb6ff8f75ad)) + +## [1.781.3](https://github.com/windmill-labs/windmill/compare/v1.781.2...v1.781.3) (2026-08-06) + + +### Bug Fixes + +* **frontend:** take the editor's post-edit content, not setCode's argument ([#10562](https://github.com/windmill-labs/windmill/issues/10562)) ([1846bd5](https://github.com/windmill-labs/windmill/commit/1846bd5ce59ee71a4262297513673d662f6a38b6)) + +## [1.781.2](https://github.com/windmill-labs/windmill/compare/v1.781.1...v1.781.2) (2026-08-06) + + +### Bug Fixes + +* **frontend:** reset the editor content when the script language changes ([#10560](https://github.com/windmill-labs/windmill/issues/10560)) ([56ea133](https://github.com/windmill-labs/windmill/commit/56ea133366875d8bd13347b5002f4ef351a8e4e6)) + +## [1.781.1](https://github.com/windmill-labs/windmill/compare/v1.781.0...v1.781.1) (2026-08-06) + + +### Bug Fixes + +* open the expression property column on demand, not from focus ([#10558](https://github.com/windmill-labs/windmill/issues/10558)) ([386c66b](https://github.com/windmill-labs/windmill/commit/386c66bef0534d4a63b3220050e98b9654ef8f34)) +* restore the flow expression editor's property side panel ([#10555](https://github.com/windmill-labs/windmill/issues/10555)) ([e4e7782](https://github.com/windmill-labs/windmill/commit/e4e7782517a9fd0fbbec020694b263a948ed1c0c)) + + +### Performance Improvements + +* keep run status out of flow graph node and edge data ([#10554](https://github.com/windmill-labs/windmill/issues/10554)) ([b2d38e0](https://github.com/windmill-labs/windmill/commit/b2d38e0391f26d92e8f9245a3db099129b6c3977)) + +## [1.781.0](https://github.com/windmill-labs/windmill/compare/v1.780.0...v1.781.0) (2026-08-05) + + +### Features + +* add a load all to the tree view's per-folder pager ([#10548](https://github.com/windmill-labs/windmill/issues/10548)) ([a3b79d7](https://github.com/windmill-labs/windmill/commit/a3b79d7732deba54fd6344229d5b91a80a3e0dd6)) +* allow a dev workspace to have its own dev workspace ([#10534](https://github.com/windmill-labs/windmill/issues/10534)) ([e203ab0](https://github.com/windmill-labs/windmill/commit/e203ab087a4ce885a1d424267f96db861207a0e6)) +* **debugger:** install debug session deps from the instance registry settings ([#10550](https://github.com/windmill-labs/windmill/issues/10550)) ([154f8f4](https://github.com/windmill-labs/windmill/commit/154f8f461ef01d60da73f9633d76bed45381a035)) +* **mcp:** serve the 2026-07-28 spec alongside the legacy protocol ([#10535](https://github.com/windmill-labs/windmill/issues/10535)) ([4fe4fac](https://github.com/windmill-labs/windmill/commit/4fe4fac358e67ee6e20dbff8bfa1de0e92e5f3c7)) +* redesign flow step, loop and branch settings panels ([#10026](https://github.com/windmill-labs/windmill/issues/10026)) ([09c8f3b](https://github.com/windmill-labs/windmill/commit/09c8f3b1f3eef2409e212cdb26c7bd2f830dae89)) +* register mounted CA certificates in windmill_extra at startup ([#10545](https://github.com/windmill-labs/windmill/issues/10545)) ([d9d6ec8](https://github.com/windmill-labs/windmill/commit/d9d6ec82ab7ad5279ba8ebc059f58bd663bad87f)) + + +### Bug Fixes + +* **ai:** collapse thinking to a status row with a thought-for duration ([#10515](https://github.com/windmill-labs/windmill/issues/10515)) ([d9b10e7](https://github.com/windmill-labs/windmill/commit/d9b10e7b0a1ba6702ecf05f64b80ac8bb7c79326)) +* **debugger:** confine prepare-deps under nsjail in both language paths ([#10546](https://github.com/windmill-labs/windmill/issues/10546)) ([a5423a8](https://github.com/windmill-labs/windmill/commit/a5423a81ca5d7c1e88ebed4573952e858ef1546c)) +* **debugger:** pass python index settings to prepare-deps and report failures ([#10533](https://github.com/windmill-labs/windmill/issues/10533)) ([7d153d5](https://github.com/windmill-labs/windmill/commit/7d153d5750db0ea17812d6b1f9a63ceaefddc2b9)) +* **debugger:** report python debugger dependency install failures instead of timing out ([#10531](https://github.com/windmill-labs/windmill/issues/10531)) ([29e179f](https://github.com/windmill-labs/windmill/commit/29e179f7879845f01c6813ad5dfbb19d08dc030e)) +* edit-in-dev-workspace dead-ends, wraps, and misses the tree view ([#10354](https://github.com/windmill-labs/windmill/issues/10354)) ([616d4fe](https://github.com/windmill-labs/windmill/commit/616d4fe167e1c99f8f5104c7b66dbc5fd502e0be)) +* flow step picker layout and single hover/keyboard highlight ([#10488](https://github.com/windmill-labs/windmill/issues/10488)) ([aa91619](https://github.com/windmill-labs/windmill/commit/aa91619bb67b7ae0cbd3a3848b6171d36967bd1f)) +* forward TLS trust roots to debug sessions and honor INIT_SCRIPT on windmill_extra ([#10532](https://github.com/windmill-labs/windmill/issues/10532)) ([74c4185](https://github.com/windmill-labs/windmill/commit/74c418570bb5bf1af54f367cfecae3e8b4ede764)) +* **frontend:** draw the tab strip's scroll bar instead of the native one ([#10547](https://github.com/windmill-labs/windmill/issues/10547)) ([2c189fe](https://github.com/windmill-labs/windmill/commit/2c189fea14749b8bb4604b1363eef907abc3b129)) +* **frontend:** filter the AI Sandbox entry by the flow insert search ([#10529](https://github.com/windmill-labs/windmill/issues/10529)) ([1dcb6bb](https://github.com/windmill-labs/windmill/commit/1dcb6bb9005c1e97f1fca6470eb2d2105a973918)) +* keep the same_worker pin across a flow module that spawns no job ([#10551](https://github.com/windmill-labs/windmill/issues/10551)) ([1aee222](https://github.com/windmill-labs/windmill/commit/1aee22296e228c54d4ca7251e9ae7e04229adb34)) +* keep the same_worker pin when a suspend ends without approval ([#10552](https://github.com/windmill-labs/windmill/issues/10552)) ([c59b60c](https://github.com/windmill-labs/windmill/commit/c59b60c729b03c5be66738b1f4cbf0d989a7b438)) +* point re-opened previews at the tab already showing them ([#10538](https://github.com/windmill-labs/windmill/issues/10538)) ([f11e883](https://github.com/windmill-labs/windmill/commit/f11e8835fdc762fccb521de48049ea43838d7f3c)) +* **triggers:** stop one failing trigger count from zeroing the rest ([#10549](https://github.com/windmill-labs/windmill/issues/10549)) ([0e42381](https://github.com/windmill-labs/windmill/commit/0e42381df086d4463e5914d8a194fe5141793bd1)) + ## [1.780.0](https://github.com/windmill-labs/windmill/compare/v1.779.0...v1.780.0) (2026-08-04) diff --git a/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json b/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json new file mode 100644 index 0000000000..c436cb3ed8 --- /dev/null +++ b/backend/.sqlx/query-051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE tree AS (\n SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,\n tree.depth + 1\n FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM tree\n WHERE depth > 0 AND is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289" +} diff --git a/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json b/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json new file mode 100644 index 0000000000..25bfaf16d3 --- /dev/null +++ b/backend/.sqlx/query-0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22" +} diff --git a/backend/.sqlx/query-3200de51b6fedd925ab3e9e471a6719b7b6de68976e9f90d18c5b74f43763ece.json b/backend/.sqlx/query-3200de51b6fedd925ab3e9e471a6719b7b6de68976e9f90d18c5b74f43763ece.json new file mode 100644 index 0000000000..adbdc13ea5 --- /dev/null +++ b/backend/.sqlx/query-3200de51b6fedd925ab3e9e471a6719b7b6de68976e9f90d18c5b74f43763ece.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN workspace.id IS NULL THEN 'deleted'\n WHEN usr.email IS NULL AND NOT $3 THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "status!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Bool" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "3200de51b6fedd925ab3e9e471a6719b7b6de68976e9f90d18c5b74f43763ece" +} diff --git a/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json b/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json new file mode 100644 index 0000000000..c1df08d9b0 --- /dev/null +++ b/backend/.sqlx/query-34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE ancestors AS (\n SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted,\n 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,\n w.deleted, ancestors.depth + 1\n FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id\n WHERE ancestors.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM ancestors\n WHERE is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "34fbd17a412a11779fe54505a8c933a2884dbb4ab59161881fdf121ab48f1b35" +} diff --git a/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json new file mode 100644 index 0000000000..28aa09d18d --- /dev/null +++ b/backend/.sqlx/query-47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, value FROM global_settings WHERE name = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d" +} diff --git a/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json b/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json new file mode 100644 index 0000000000..75871561e9 --- /dev/null +++ b/backend/.sqlx/query-79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "79db50d906264ab96dffa134673f9171b943e959eb5401a496af850aa686391b" +} diff --git a/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json b/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json new file mode 100644 index 0000000000..f6598ded42 --- /dev/null +++ b/backend/.sqlx/query-ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM workspace\n WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ab906821072ddaa6303c5ea632150b5db1bf5a11d7a162130ebb826178c4ea08" +} diff --git a/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json b/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json new file mode 100644 index 0000000000..b825e2146e --- /dev/null +++ b/backend/.sqlx/query-b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS \"cycle!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "cycle!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b0a7963de04faccae3262823dc98f2faa8ef6ead3f797a49be9f5b075bad1490" +} diff --git a/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json b/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json new file mode 100644 index 0000000000..a698761d8a --- /dev/null +++ b/backend/.sqlx/query-e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (parent_workspace_id IS NOT NULL) AS \"is_fork!\", is_dev_workspace, deleted\n FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_fork!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + false, + false + ] + }, + "hash": "e9ea69ee2a7927e18a2bae9721407e05b1ab61512e00034982dfd9b4ac3257aa" +} diff --git a/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json b/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json new file mode 100644 index 0000000000..ac9b645b01 --- /dev/null +++ b/backend/.sqlx/query-ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id),\n up AS (\n SELECT w.id, w.parent_workspace_id, 0 AS depth\n FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, w.parent_workspace_id, up.depth + 1\n FROM workspace w JOIN up ON w.id = up.parent_workspace_id\n WHERE up.depth < 20\n ),\n down AS (\n SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id\n UNION ALL\n SELECT w.id, down.depth + 1\n FROM workspace w JOIN down ON w.parent_workspace_id = down.id\n WHERE down.depth < 20 AND w.is_dev_workspace\n )\n SELECT id AS \"id!\" FROM (\n SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down\n ) n ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ebe7822c6fbdd8afea833e96cfd65e3f5fe96ff6dee2c6d41798328a3852fe76" +} diff --git a/backend/.sqlx/query-fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441.json b/backend/.sqlx/query-fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441.json deleted file mode 100644 index 484dd23e2a..0000000000 --- a/backend/.sqlx/query-fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT req.id AS \"id!\",\n (CASE\n WHEN usr.email IS NULL THEN 'deleted'\n WHEN workspace.deleted THEN 'archived'\n ELSE 'active'\n END) AS \"status!\"\n FROM unnest($1::text[]) AS req(id)\n LEFT JOIN workspace ON workspace.id = req.id\n LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "status!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "TextArray", - "Text" - ] - }, - "nullable": [ - null, - null - ] - }, - "hash": "fc4583d1570f3a2a428bb28390ca72e61719fae68aa4b42730f9076f3bd97441" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 204bafda83..9743b85d7b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -178,9 +178,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -1832,9 +1832,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", @@ -2318,12 +2318,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -2945,6 +2939,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + [[package]] name = "darling_core" version = "0.13.4" @@ -3000,6 +3004,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.13.4" @@ -3044,6 +3061,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core 0.24.0", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -6620,27 +6648,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.19", "walkdir", - "windows-sys 0.45.0", + "windows-link 0.2.1", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.119", ] [[package]] @@ -6674,11 +6707,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -6794,9 +6828,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "kstat-rs" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27964e4632377753acb0898ce6f28770d50cbca1339200ae63d700cff97b5c2b" +checksum = "52d0786643a0b49f595bd1fc81e1c1aa7bad8555bc820c6892f4d28cb20cf210" dependencies = [ "libc", "thiserror 1.0.69", @@ -10304,15 +10338,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79" +checksum = "737d947bcfd946fae6a179a4ef6487be6dcf25c930c2393856b820f1386e52a6" dependencies = [ - "darling 0.23.0", + "darling 0.24.0", "proc-macro2", "quote", "serde_json", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -10673,9 +10707,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -11480,6 +11514,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -14237,9 +14281,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -14251,23 +14295,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14275,9 +14315,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -14288,18 +14328,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.58" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" dependencies = [ "async-trait", "cast", @@ -14319,9 +14359,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.58" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" dependencies = [ "proc-macro2", "quote", @@ -14330,9 +14370,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" [[package]] name = "wasm-streams" @@ -14386,9 +14426,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -14530,7 +14570,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-nats", @@ -14615,7 +14655,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.780.0" +version = "1.782.0" dependencies = [ "async-stream", "async-trait", @@ -14648,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14661,7 +14701,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "argon2", @@ -14801,7 +14841,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14824,7 +14864,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14841,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14867,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.780.0" +version = "1.782.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14877,7 +14917,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14894,7 +14934,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14916,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14939,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14955,7 +14995,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14977,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14998,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15012,7 +15052,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-nats", @@ -15047,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15072,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "flate2", @@ -15090,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15112,7 +15152,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15132,7 +15172,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15170,7 +15210,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15198,7 +15238,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.780.0" +version = "1.782.0" dependencies = [ "lazy_static", "serde", @@ -15210,7 +15250,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.780.0" +version = "1.782.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15235,7 +15275,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15249,7 +15289,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.780.0" +version = "1.782.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15284,7 +15324,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.780.0" +version = "1.782.0" dependencies = [ "chrono", "lazy_static", @@ -15298,7 +15338,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15317,7 +15357,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.780.0" +version = "1.782.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15421,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.780.0" +version = "1.782.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15440,7 +15480,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.780.0" +version = "1.782.0" dependencies = [ "regex", "serde", @@ -15455,7 +15495,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15479,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "futures", @@ -15496,7 +15536,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.780.0" +version = "1.782.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15512,7 +15552,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -15533,7 +15573,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -15564,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "arc-swap", @@ -15589,7 +15629,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-stream", @@ -15623,7 +15663,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "futures", @@ -15641,7 +15681,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.780.0" +version = "1.782.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15650,7 +15690,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15662,7 +15702,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -15674,7 +15714,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "gosyn", @@ -15686,7 +15726,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15698,7 +15738,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -15710,7 +15750,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "nu-parser", @@ -15721,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15732,7 +15772,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15744,7 +15784,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15755,7 +15795,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-recursion", @@ -15777,7 +15817,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -15789,7 +15829,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15803,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15820,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15833,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde", @@ -15845,7 +15885,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15863,7 +15903,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15879,7 +15919,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15895,7 +15935,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -15909,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-recursion", @@ -15948,7 +15988,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "const_format", @@ -15988,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.780.0" +version = "1.782.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15999,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-recursion", @@ -16033,7 +16073,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16057,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16090,7 +16130,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16117,7 +16157,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16150,7 +16190,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16170,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16204,7 +16244,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16240,7 +16280,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16263,7 +16303,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16287,7 +16327,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-nats", @@ -16311,7 +16351,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16346,7 +16386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16374,7 +16414,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-trait", @@ -16399,7 +16439,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16418,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-once-cell", @@ -16534,7 +16574,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.780.0" +version = "1.782.0" dependencies = [ "bytes", "futures", @@ -16808,15 +16848,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -16862,21 +16893,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -16934,12 +16950,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -16958,12 +16968,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -16982,12 +16986,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -17018,12 +17016,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -17042,12 +17034,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -17066,12 +17052,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -17090,12 +17070,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0096cff28b..fbc48186a9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.780.0" +version = "1.782.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.780.0" +version = "1.782.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9857d2eac9..de812b6cff 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -7622c1df38a1f858bd3f893537da0e2cca6c2d54 +2656a901804c0ae791ab8e7e6714edc900d6c62a diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index c210438ae1..0810b35e6d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.780.0" +version = "1.782.0" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.780.0" +version = "1.782.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.780.0" +version = "1.782.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.780.0" +version = "1.782.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ce6e678246..c6369aad52 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.780.0" +version = "1.782.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 336956415e..ac9ba0ce70 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -119,15 +119,15 @@ use windmill_queue::{ }; use windmill_worker::{ result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel, - OtelTracingProxySettings, SameWorkerSender, WorkspaceRegistryMap, BUNFIG_INSTALL_SCOPES, - 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, - NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, - SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, - SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + OtelTracingProxySettings, SameWorkerSender, StepFailureKind, WorkspaceRegistryMap, + BUNFIG_INSTALL_SCOPES, 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, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, + PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, + SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, + SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -4700,7 +4700,12 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n memory_peak, None, error::Error::ExecutionErr(error_message.clone()), - matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie + // a same worker zombie means the worker itself is gone + if matches!(error_kind, ErrorMessage::SameWorker) { + StepFailureKind::Unrecoverable + } else { + StepFailureKind::Normal + }, Some(&same_worker_tx_never_used), "", node_name, diff --git a/backend/tests/fixtures/session_workspace_status.sql b/backend/tests/fixtures/session_workspace_status.sql new file mode 100644 index 0000000000..1d6dbee738 --- /dev/null +++ b/backend/tests/fixtures/session_workspace_status.sql @@ -0,0 +1,15 @@ +-- Extends base.sql with two workspaces `test@windmill.dev` (the instance superadmin) has no +-- `usr` row in: one live, one soft-deleted. The third no-membership case, `admins`, is +-- created by migration with no `usr` rows at all and needs no fixture. + +INSERT INTO workspace (id, name, owner, deleted) VALUES + ('foreign-workspace', 'foreign-workspace', 'someone-else', false), + ('archived-workspace', 'archived-workspace', 'someone-else', true); + +INSERT INTO workspace_key(workspace_id, kind, key) VALUES + ('foreign-workspace', 'cloud', 'test-key'), + ('archived-workspace', 'cloud', 'test-key'); + +INSERT INTO workspace_settings (workspace_id) VALUES + ('foreign-workspace'), + ('archived-workspace'); diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index e95748d8de..317b1c6866 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -83,6 +83,32 @@ async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCo (status, body) } +async fn post_json( + url: &str, + token: Option<&str>, + body: serde_json::Value, +) -> (reqwest::StatusCode, String) { + let mut req = client().post(url).json(&body); + if let Some(token) = token { + req = req.header("Authorization", format!("Bearer {token}")); + } + let resp = req.send().await.expect("request"); + let status = resp.status(); + let body = resp.text().await.expect("body"); + (status, body) +} + +async fn delete(url: &str, token: Option<&str>) -> (reqwest::StatusCode, String) { + let mut req = client().delete(url); + if let Some(token) = token { + req = req.header("Authorization", format!("Bearer {token}")); + } + let resp = req.send().await.expect("request"); + let status = resp.status(); + let body = resp.text().await.expect("body"); + (status, body) +} + #[sqlx::test(fixtures("base", "jobs_read_auth"))] async fn test_single_job_read_authorization(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; @@ -92,6 +118,8 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u"); // result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`. let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + let rules_url = + format!("http://localhost:{port}/api/w/test-workspace/workspaces/protection_rules"); // The endpoints that return the victim job's sensitive data by UUID. let endpoints = [ @@ -507,6 +535,161 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul "a non-reader must not be able to mint a share token (got {status})" ); + // ---- PUBLIC SHARE READ LINK (public view_token) ---- + // A member-audience token must never turn into a public one: links already handed + // out stay confined to logged-in members. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}?view_token={token}"), + None, + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "a member view_token must not grant unauthenticated read (got {status}): {body}" + ); + assert!( + !body.contains(RESULT_SECRET), + "unauth body must not leak with a member token: {body}" + ); + + // The owner mints the public flavor for the top flow. + let (status, mint_body) = get( + &authed_base, + &format!("job_public_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner must be able to mint a public share token (got {status}): {mint_body}" + ); + let public_token = mint_body.trim().trim_matches('"').to_string(); + + // It grants a logged-out visitor the shared job and its whole flow subtree. + for path in [ + format!("get/{TOP_SECRET_FLOW}?view_token={public_token}"), + format!("get_args/{TOP_SECRET_FLOW}?view_token={public_token}"), + format!("getupdate/{TOP_SECRET_FLOW}?view_token={public_token}"), + format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"), + format!("get_logs/{DEEP_LEAF_JOB}?view_token={public_token}"), + ] { + let (status, body) = get(&base, &path, None).await; + assert!( + status.is_success(), + "a public view_token must grant unauthenticated read of {path} (got {status}): {body}" + ); + } + + // Same scoping as the member flavor: another job, and a tampered signature, are refused. + for path in [ + format!("get/{VICTIM}?view_token={public_token}"), + format!("get/{TOP_SECRET_FLOW}?view_token={TOP_SECRET_FLOW}.deadbeef"), + ] { + let (status, body) = get(&base, &path, None).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "an out-of-scope or forged public token must not grant read of {path} (got {status}): {body}" + ); + } + + // The public audience is a superset of the member one: it must also satisfy the + // authenticated ACL check, so a viewer handed a public link is not worse off. + let (status, body) = get( + &base, + &format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={public_token}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "a public view_token must also grant an authenticated member read (got {status}): {body}" + ); + + // Tag-scoped caller, job inside its scope: the member flavor is allowed (asserted + // further down), the public one must not be — i.e. strictly stricter. + let (status, body) = get( + &authed_base, + &format!("job_public_view_token/{VICTIM}"), + Some("SCOPED_DENO_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "a tag-scoped token must NOT mint a public link, even for an in-scope job (got {status}): {body}" + ); + + // Minting the public flavor takes the same read access as the member one. + let (status, _) = get( + &authed_base, + &format!("job_public_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "a non-reader must not be able to mint a public share token (got {status})" + ); + + // Publishing a run is gated by `RestrictPublicRunSharing`, so a wrong bitflag or + // rule-kind arm would silently let a restricted member expose one. Admins bypass, and + // the member flavor is untouched by the rule. + let (status, body) = post_json( + &rules_url, + Some("SECRET_TOKEN"), + serde_json::json!({ + "name": "no-public-runs", + "rules": ["RestrictPublicRunSharing"], + "bypass_users": [], + "bypass_groups": [] + }), + ) + .await; + assert!( + status.is_success(), + "admin should create the protection rule (got {status}): {body}" + ); + let (status, body) = get( + &authed_base, + &format!("job_public_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "the rule must block a non-admin owner from minting a public link (got {status}): {body}" + ); + let (status, body) = get( + &authed_base, + &format!("job_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "the rule must NOT affect the member flavor (got {status}): {body}" + ); + let (status, body) = get( + &authed_base, + &format!("job_public_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN"), + ) + .await; + assert!( + status.is_success(), + "an admin must bypass the rule (got {status}): {body}" + ); + + // Later assertions in this test mint public tokens; drop the rule so they see the + // same workspace state as before. + let (status, _) = delete(&format!("{rules_url}/no-public-runs"), Some("SECRET_TOKEN")).await; + assert!(status.is_success(), "admin should delete the rule"); + // ---- TAG-SCOPED token must not mint a token outside its allowed tags ---- // SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both // VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the @@ -618,6 +801,36 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul "viewer must not cancel another user's job ({path}, got {status}): {body}" ); } + // A public share link grants read, never the right to kill the run: cancelling stays + // confined to anonymously-created jobs for a logged-out caller. + let (status, mint_body) = get( + &authed_base, + &format!("job_public_view_token/{RUNNING_JOB}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!(status.is_success(), "owner mints for the running job"); + let running_public = mint_body.trim().trim_matches('"').to_string(); + let (status, body) = get( + &base, + &format!("get/{RUNNING_JOB}?view_token={running_public}"), + None, + ) + .await; + assert!( + status.is_success(), + "the public token must grant the anonymous read it is for (got {status}): {body}" + ); + let (status, body) = post( + &base, + &format!("queue/cancel/{RUNNING_JOB}?view_token={running_public}"), + None, + ) + .await; + assert!( + !status.is_success(), + "a public token must not let an anonymous caller cancel the run (got {status}): {body}" + ); // The owner still cancels their own job (no over-blocking). Keep this last: it // takes RUNNING_JOB out of the queue. let (status, body) = post( diff --git a/backend/tests/session_workspace_status.rs b/backend/tests/session_workspace_status.rs new file mode 100644 index 0000000000..b4fc98da5e --- /dev/null +++ b/backend/tests/session_workspace_status.rs @@ -0,0 +1,62 @@ +//! `POST /workspaces/session_workspace_status` is what the client uses to decide whether to +//! keep or destroy an AI session, so its notion of "reachable" must match what the authed +//! extractor actually grants. Membership is not the only path: a superadmin is authed into +//! any existing workspace without a `usr` row, and `admins` has no `usr` rows at all, so +//! answering from `usr` alone reports live workspaces as unresolvable and the client deletes +//! sessions that still work. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use std::collections::HashMap; +use windmill_test_utils::*; + +async fn status(port: u16, token: &str, ids: &[&str]) -> anyhow::Result> { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/workspaces/session_workspace_status" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "workspace_ids": ids })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(resp.json().await?) +} + +#[sqlx::test(fixtures("base", "session_workspace_status"))] +async fn test_superadmin_reaches_workspaces_without_a_usr_row( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let ids = [ + "admins", + "foreign-workspace", + "archived-workspace", + "test-workspace", + "no-such-workspace", + ]; + + // test@windmill.dev is an instance superadmin, and a member of test-workspace only. + let sa = status(port, "SECRET_TOKEN", &ids).await?; + assert_eq!(sa["admins"], "active"); + assert_eq!(sa["foreign-workspace"], "active"); + // Reachable, but soft-deleted: superadmins must not bypass the archived state. + assert_eq!(sa["archived-workspace"], "archived"); + assert_eq!(sa["test-workspace"], "active"); + // A workspace that never existed stays unresolvable — the superadmin arm must not + // swallow the hard-deleted case, or those sessions would linger forever. + assert_eq!(sa["no-such-workspace"], "deleted"); + + // test2@windmill.dev is not a superadmin, and a member of test-workspace only. + let usr = status(port, "SECRET_TOKEN_2", &ids).await?; + assert_eq!(usr["admins"], "deleted"); + assert_eq!(usr["foreign-workspace"], "deleted"); + assert_eq!(usr["archived-workspace"], "deleted"); + assert_eq!(usr["test-workspace"], "active"); + assert_eq!(usr["no-such-workspace"], "deleted"); + + Ok(()) +} diff --git a/backend/tests/suspend_resume.rs b/backend/tests/suspend_resume.rs index 29fe38d2c3..d112ca0b96 100644 --- a/backend/tests/suspend_resume.rs +++ b/backend/tests/suspend_resume.rs @@ -235,6 +235,88 @@ mod suspend_resume { Ok(()) } + /// A suspend gate that ends without approval leaves the worker that ran the approval step + /// alive, so the error handler it routes to must stay pinned to that worker rather than + /// being unpinned and routed by tag — which would break the `./shared` contract of a + /// `same_worker` flow. + #[cfg(feature = "deno_core")] + #[sqlx::test(fixtures("base"))] + async fn disapproved_suspend_keeps_same_worker_pin(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let value: FlowValue = serde_json::from_value(json!({ + "same_worker": true, + "modules": [{ + "id": "a", + "value": { + "input_transforms": { + "port": { "type": "javascript", "expr": "flow_input.port" }, + }, + "type": "rawscript", + "language": "deno", + "content": "\ + export async function main(port) {\ + const job = Deno.env.get('WM_JOB_ID');\ + const token = Deno.env.get('WM_TOKEN');\ + const secret = await (await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\ + { headers: { 'Authorization': `Bearer ${token}` } }\ + )).text();\ + await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs_u/cancel/${job}/0/${secret}?approver=ruben`,\ + { method: 'POST', body: JSON.stringify('from job'), headers: { 'content-type': 'application/json' } }\ + );\ + return 'a ran';\ + }", + }, + "suspend": { "required_events": 1 }, + }, { + "id": "b", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'b ran' }", + }, + // The gate holds `b` back, so `b` never runs and its error policy describes + // nothing: honouring it here would skip `b` instead of reaching the handler. + "continue_on_error": true, + }], + "failure_module": { + "id": "failure", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'handled' }", + }, + }, + }))?; + + let completed = + RunJob::from(JobPayload::RawFlow { value, path: None, restarted_from: None }) + .arg("port", json!(port)) + .run_until_complete(&db, false, port) + .await; + + server.close().await.unwrap(); + + assert_eq!(json!("handled"), completed.json_result().unwrap()); + + let same_worker: Option = sqlx::query_scalar( + "SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'failure'", + ) + .bind(completed.id) + .fetch_one(&db) + .await?; + assert_eq!(Some(true), same_worker); + + Ok(()) + } + /// Test that self-approval is blocked when self_approval_disabled is true. /// /// This test verifies that when a flow has an approval step with self_approval_disabled=true, diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 32fd372e94..08320eb835 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -587,6 +587,69 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { ); Ok(()) } + +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_same_worker_survives_empty_branch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server: ApiServer = ApiServer::start(db.clone()).await?; + + // No branch matches and the default is empty, so `a` completes without spawning a job and + // hands the flow back over the UpdateFlow channel. `b` must still be pinned to the worker. + let flow: FlowValue = serde_json::from_value(json!({ + "same_worker": true, + "modules": [ + { + "id": "a", + "value": { + "type": "branchone", + "branches": [{ + "expr": "false", + "modules": [{ + "id": "c", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(){ return 1 }", + } + }] + }], + "default": [] + } + }, + { + "id": "b", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(){ return 42 }", + } + } + ] + })) + .unwrap(); + + let job = run_job_in_new_worker_until_complete( + &db, + false, + JobPayload::RawFlow { value: flow, path: None, restarted_from: None }, + server.addr.port(), + ) + .await; + assert_eq!(job.json_result().unwrap(), json!(42)); + + let same_worker: Option = sqlx::query_scalar( + "SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'", + ) + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(same_worker, Some(true)); + + Ok(()) +} + #[sqlx::test(fixtures("base"))] async fn test_flow_result_by_id(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; @@ -3992,7 +4055,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { /// Push `flow`, run it on a real worker until its first step is running, then simulate /// `monitor::handle_zombie_jobs` reaping that step unrecoverably (its worker crashed/OOM'd) -/// by calling `handle_job_error(..., unrecoverable = true, ...)` exactly as the monitor does. +/// by calling `handle_job_error(..., StepFailureKind::Unrecoverable, ...)` exactly as the monitor does. /// Returns the flow's completed result. #[cfg(feature = "deno_core")] async fn run_flow_until_step_running_then_fail_unrecoverably( @@ -4007,7 +4070,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( use windmill_common::client::AuthedClient; use windmill_common::KillpillSender; use windmill_queue::{get_queued_job_v2, MiniCompletedJob, SameWorkerPayload}; - use windmill_worker::{JobCompletedSender, SameWorkerSender}; + use windmill_worker::{JobCompletedSender, SameWorkerSender, StepFailureKind}; let flow_id = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) @@ -4072,7 +4135,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( windmill_common::error::Error::ExecutionErr( "simulated worker OOM crash".to_string(), ), - true, // unrecoverable + StepFailureKind::Unrecoverable, Some(&sw_tx), "", "test-monitor", diff --git a/backend/windmill-api-debug/Cargo.toml b/backend/windmill-api-debug/Cargo.toml index 8531407416..c262ac3d32 100644 --- a/backend/windmill-api-debug/Cargo.toml +++ b/backend/windmill-api-debug/Cargo.toml @@ -8,6 +8,10 @@ edition.workspace = true name = "windmill_api_debug" path = "src/lib.rs" +[features] +default = [] +enterprise = [] + [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 3b43a04ad1..7b2f569821 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -20,15 +20,21 @@ //! - A job entry in v2_job (kind=preview) for traceability //! - A completed job entry in v2_job_completed //! - An audit log entry identical to script preview runs +//! +//! The same signature is what authorizes the debugger's requests back to the API: +//! /api/debug/registry_config serves the instance's dependency-registry settings, which the +//! debugger cannot read for itself, to sessions whose token carries the `registry_config` +//! claim. use axum::{ extract::Path, + http::HeaderMap, routing::{get, post}, Extension, Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::Utc; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::types::Json as SqlxJson; @@ -37,8 +43,18 @@ use tokio::sync::RwLock; use uuid::Uuid; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ - db::UserDB, error::JsonResult, jobs::JobKind, jwt::JWT_SECRET, scripts::ScriptLang, + db::UserDB, + error::{Error, JsonResult}, + global_settings::{ + BUNFIG_INSTALL_SCOPES_SETTING, EXTRA_PIP_INDEX_URL_SETTING, NPMRC_SETTING, + NPM_CONFIG_REGISTRY_SETTING, PIP_INDEX_URL_SETTING, UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, + }, + jobs::JobKind, + jwt::JWT_SECRET, + scripts::ScriptLang, users::username_to_permissioned_as, + DB, }; use windmill_api_auth::ApiAuthed; @@ -112,7 +128,9 @@ pub async fn reload_debug_signing_key() { } pub fn global_service() -> Router { - Router::new().route("/jwks", get(get_jwks)) + Router::new() + .route("/jwks", get(get_jwks)) + .route("/registry_config", get(get_registry_config)) } pub fn workspaced_service() -> Router { @@ -168,6 +186,220 @@ async fn get_jwks() -> JsonResult { })) } +/// The instance's dependency-registry configuration, as `windmill prepare-deps` consumes it. +/// Field names are the `global_settings` keys, so the debug service forwards this object to +/// the CLI as-is. +#[derive(Serialize, Default)] +pub struct DebugRegistryConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub npm_config_registry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub npmrc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bunfig_install_scopes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_extra_index_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uv_index_strategy: Option, + /// Why configured settings were withheld, for the debug service to show the user. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Placeholder an EE instance puts in an index URL for a token minted per install by +/// `EPHEMERAL_TOKEN_CMD` (`windmill-worker`'s `handle_ephemeral_token`). +const EPHEMERAL_TOKEN_MARKER: &str = "EPHEMERAL_TOKEN"; + +/// Every `global_settings` key [`get_registry_config`] reads, in one query. A setting +/// resolved there but missing here reads as unset, whatever the instance has stored. +const REGISTRY_SETTINGS: [&str; 7] = [ + NPM_CONFIG_REGISTRY_SETTING, + NPMRC_SETTING, + BUNFIG_INSTALL_SCOPES_SETTING, + PIP_INDEX_URL_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, + UV_INDEX_STRATEGY_SETTING, + WORKSPACE_REGISTRIES_SETTING, +]; + +/// Which half of the settings a session installs with, from the language its token was signed +/// for: a session is served only what its own installer runs on, so a token minted for one +/// language cannot be replayed to read the other's credentials. Every language the debugger +/// accepts (`isDebuggableLanguage` in the frontend) has to appear here, or its sessions +/// silently install from the public registries. +fn registry_settings_for_language(language: &str) -> (bool, bool) { + match language { + "bun" | "typescript" | "deno" | "nativets" => (true, false), + "python3" | "python" => (false, true), + _ => (false, false), + } +} + +/// Resolve one setting the way a worker resolves it: a workspace override wins over the +/// instance value, which is `FORCE_` > `global_settings` > `` (the server's +/// `load_option_setting_value`). A blank value means unset from either source, so a +/// workspace can blank one out. +fn resolve_registry_setting( + stored: &std::collections::HashMap, + workspace_overrides: Option<&serde_json::Value>, + key: &str, + env_var: &str, +) -> Option { + let as_str = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(|s| s.to_string()); + let instance_value = std::env::var(format!("FORCE_{env_var}")) + .ok() + .or_else(|| as_str(stored.get(key))) + .or_else(|| std::env::var(env_var).ok()); + as_str(workspace_overrides.and_then(|w| w.get(key))) + .or(instance_value) + .filter(|v| !v.trim().is_empty()) +} + +/// Serve the dependency-registry settings to the debug service. +/// +/// `windmill prepare-deps` installs a debug session's imports without a database +/// connection, so the service fetches the settings here and passes them down over the +/// CLI's stdin request. They stop there: a private index URL embeds credentials and the +/// debugged script can read its own process, so nothing served here reaches the session's +/// environment (see `debugger/README.md`). +/// +/// Authorized by the launch token the service verified for that session, and only when the +/// token carries the `registry_config` claim (see [`sign_debug_request`] for what it means). +async fn get_registry_config( + Extension(db): Extension, + headers: HeaderMap, +) -> JsonResult { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| Error::NotAuthorized("Missing debug token".to_string()))?; + + let claims = verify_debug_token(token).await?; + if !claims.registry_config { + return Err(Error::NotAuthorized( + "This debug session is not allowed to read the registry configuration".to_string(), + )); + } + + let names = REGISTRY_SETTINGS.map(String::from); + let stored = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &names[..] + ) + .fetch_all(&db) + .await? + .into_iter() + .map(|r| (r.name, r.value)) + .collect::>(); + + let workspace_overrides = stored + .get(WORKSPACE_REGISTRIES_SETTING) + .and_then(|v| v.get(&claims.workspace_id)); + let (npm, python) = registry_settings_for_language(&claims.language); + let resolve = |serve: bool, key: &str, env_var: &str| { + serve + .then(|| resolve_registry_setting(&stored, workspace_overrides, key, env_var)) + .flatten() + }; + + let mut config = DebugRegistryConfig { + npm_config_registry: resolve(npm, NPM_CONFIG_REGISTRY_SETTING, "NPM_CONFIG_REGISTRY"), + npmrc: resolve(npm, NPMRC_SETTING, "NPMRC"), + bunfig_install_scopes: resolve( + npm, + BUNFIG_INSTALL_SCOPES_SETTING, + "BUNFIG_INSTALL_SCOPES", + ), + pip_index_url: resolve(python, PIP_INDEX_URL_SETTING, "PIP_INDEX_URL"), + pip_extra_index_url: resolve(python, EXTRA_PIP_INDEX_URL_SETTING, "PIP_EXTRA_INDEX_URL"), + // Not a private-registry setting: a worker reads it on any edition, so it is + // served below the Enterprise gate too. + uv_index_strategy: resolve(python, UV_INDEX_STRATEGY_SETTING, "UV_INDEX_STRATEGY"), + message: None, + }; + + if cfg!(feature = "enterprise") { + // A worker substitutes this marker with the output of `EPHEMERAL_TOKEN_CMD` + // (`handle_ephemeral_token`), a command the debug service has no way to run. Serving + // the placeholder would install with a literal token, so the value is withheld and + // the service falls back to the index URL in its own environment. + let ephemeral = |url: &Option| { + url.as_ref() + .is_some_and(|u| u.contains(EPHEMERAL_TOKEN_MARKER)) + }; + if ephemeral(&config.pip_index_url) || ephemeral(&config.pip_extra_index_url) { + config.pip_index_url = None; + config.pip_extra_index_url = None; + config.message = Some(format!( + "Python index configuration ignored: an {EPHEMERAL_TOKEN_MARKER} index URL can only be resolved on a worker" + )); + } + } else { + // A private registry is an Enterprise feature, and `read_ee_registry` drops these + // same settings on a CE worker, so a CE debug session installs from the public registries + // and says why, instead of gaining a capability jobs on that instance don't have. + let configured = config.npm_config_registry.is_some() + || config.npmrc.is_some() + || config.bunfig_install_scopes.is_some() + || config.pip_index_url.is_some() + || config.pip_extra_index_url.is_some(); + config.npm_config_registry = None; + config.npmrc = None; + config.bunfig_install_scopes = None; + config.pip_index_url = None; + config.pip_extra_index_url = None; + if configured { + config.message = Some( + "Private registry configuration ignored: this feature requires Windmill Enterprise Edition" + .to_string(), + ); + } + } + + Ok(Json(config)) +} + +/// Verify a token minted by [`sign_debug_request`] and return its claims. +/// +/// The debug service verifies the same token itself against the JWKS public key; this is +/// the server-side half, for the requests the service makes back on a session's behalf. +async fn verify_debug_token(token: &str) -> Result { + let key_guard = DEBUG_SIGNING_KEY.read().await; + let signing_key = key_guard + .as_ref() + .ok_or_else(|| Error::InternalErr("Debug signing key not initialized".to_string()))?; + + let invalid = || Error::NotAuthorized("Invalid debug token".to_string()); + let mut parts = token.split('.'); + let (header_b64, claims_b64, signature_b64) = + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(header), Some(claims), Some(signature), None) => (header, claims, signature), + _ => return Err(invalid()), + }; + + let signature = Signature::from_slice( + &URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?; + signing_key + .verifying_key() + .verify_strict(format!("{header_b64}.{claims_b64}").as_bytes(), &signature) + .map_err(|_| invalid())?; + + let claims: DebugTokenClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + if Utc::now().timestamp() > claims.exp { + return Err(Error::NotAuthorized("Debug token expired".to_string())); + } + Ok(claims) +} + #[derive(Deserialize)] pub struct SignDebugRequest { /// The code to be debugged @@ -193,6 +425,11 @@ pub struct DebugTokenClaims { pub exp: i64, /// Job ID for traceability pub job_id: String, + /// Whether this session may be served the instance's dependency-registry settings + /// (see [`get_registry_config`]). Defaults to `false` so a token that predates the + /// claim is refused rather than silently trusted. + #[serde(default)] + pub registry_config: bool, } #[derive(Serialize)] @@ -248,6 +485,14 @@ async fn sign_debug_request( iat: now_ts, exp, job_id: job_id.to_string(), + // The registry settings embed credentials, and the token reaches the browser, so they + // are only served for a session whose author can already install with them: someone + // who can run a preview job. For npm that discloses nothing new, since a worker leaves + // the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in; the + // Python index URL only ever appears as uv's argv, so serving it here does widen what + // a member of the workspace can read. Operators cannot run previews at all, so their + // sessions install from the public registries. + registry_config: !authed.is_operator, }; // Create JWT manually with Ed25519 signature @@ -504,3 +749,107 @@ async fn sign_multiplayer( Ok(Json(SignedMultiplayerPayload { token })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Sign `claims` the way [`sign_debug_request`] does, with a key only this test knows. + async fn signed(claims: &DebugTokenClaims) -> String { + let key = derive_signing_key_from_jwt_secret("test-secret"); + *DEBUG_SIGNING_KEY.write().await = Some(key.clone()); + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap()); + let message = format!("{header}.{payload}"); + let signature = URL_SAFE_NO_PAD.encode(key.sign(message.as_bytes()).to_bytes()); + format!("{message}.{signature}") + } + + fn claims(exp_in: i64) -> DebugTokenClaims { + DebugTokenClaims { + code_hash: "0".repeat(32), + language: "bun".to_string(), + workspace_id: "test".to_string(), + email: "user@windmill.dev".to_string(), + iat: Utc::now().timestamp(), + exp: Utc::now().timestamp() + exp_in, + job_id: Uuid::nil().to_string(), + registry_config: true, + } + } + + /// The registry settings are credentials, and this signature is the only thing standing + /// between them and any caller of `/api/debug/registry_config`. Each rejection here is a + /// way in if it stops being checked: an edited claim, a session whose author may not read + /// them, or a token replayed long after its session. + #[tokio::test] + async fn only_an_unexpired_token_with_the_claim_verifies() { + let token = signed(&claims(60)).await; + assert!(verify_debug_token(&token).await.is_ok()); + + let no_claim = signed(&DebugTokenClaims { registry_config: false, ..claims(60) }).await; + assert!(!verify_debug_token(&no_claim).await.unwrap().registry_config); + + let expired = signed(&claims(-1)).await; + assert!(verify_debug_token(&expired).await.is_err()); + + // Re-signing is the only way to change a claim: swapping the payload of a valid token + // for one that grants itself the claim must not verify. + let (header, rest) = token.split_once('.').unwrap(); + let (_, signature) = rest.split_once('.').unwrap(); + let forged = URL_SAFE_NO_PAD.encode( + serde_json::to_string(&DebugTokenClaims { exp: i64::MAX, ..claims(60) }).unwrap(), + ); + assert!( + verify_debug_token(&format!("{header}.{forged}.{signature}")) + .await + .is_err() + ); + } + + /// Every language the debugger can start a session for installs dependencies, so each one + /// has to name the settings its installer reads. A language missing here is not a refusal: + /// its sessions quietly install from the public registries instead. + #[test] + fn every_debuggable_language_is_served_its_own_settings() { + // Mirrors `isDebuggableLanguage` in frontend/src/lib/components/debug/debugUtils.ts. + for language in ["bun", "typescript", "deno", "nativets"] { + assert_eq!(registry_settings_for_language(language), (true, false)); + } + assert_eq!(registry_settings_for_language("python3"), (false, true)); + assert_eq!(registry_settings_for_language("go"), (false, false)); + } + + /// A debug session must resolve a registry setting to what a job in the same workspace + /// resolves it to (`read_ee_registry_with_workspace_override`): the workspace override + /// replaces the instance value, and a blank value from either source means unset, which + /// is how a workspace opts out of an instance-wide registry. + #[test] + fn workspace_override_replaces_the_instance_value() { + let stored = [( + NPM_CONFIG_REGISTRY_SETTING.to_string(), + serde_json::json!("https://instance.example/"), + )] + .into_iter() + .collect::>(); + // Never set, so the environment fallback stays out of the comparison. + let env_var = "WM_TEST_DEBUG_REGISTRY_UNSET"; + let resolve = |overrides: Option<&serde_json::Value>| { + resolve_registry_setting(&stored, overrides, NPM_CONFIG_REGISTRY_SETTING, env_var) + }; + + assert_eq!(resolve(None).as_deref(), Some("https://instance.example/")); + let workspace = serde_json::json!({ "npm_config_registry": "https://workspace.example/" }); + assert_eq!( + resolve(Some(&workspace)).as_deref(), + Some("https://workspace.example/") + ); + let blanked = serde_json::json!({ "npm_config_registry": " " }); + assert_eq!(resolve(Some(&blanked)), None); + let unrelated = serde_json::json!({ "npmrc": "//other/:_authToken=x" }); + assert_eq!( + resolve(Some(&unrelated)).as_deref(), + Some("https://instance.example/") + ); + } +} diff --git a/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql b/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql new file mode 100644 index 0000000000..76f266bb0f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/nested_dev_workspace.sql @@ -0,0 +1,76 @@ +-- Seven families for the nested dev-workspace (dev of a dev) guards. +-- +-- Family A is rooted at `test-workspace` (base fixture) and is the one a nested dev is attached to: +-- test-workspace -> tw-dev ('dev') +-- plus two standalone attach candidates, one of which already owns a 'dev'-labelled dev workspace. +-- +-- Family B carries a `wm-fork-` workspace re-designated as a dev workspace, which is the shape that +-- returns to being a throwaway fork on detach: +-- prod-b -> wm-fork-redev ('dev') -> redev-dev ('staging') +-- +-- Family C is the ordinary prefix-less nesting. Detaching its middle workspace is fine (it returns +-- to standalone and goes on hosting `c-dev-dev`), but archiving it is not: +-- prod-c -> c-dev ('dev') -> c-dev-dev ('staging') +-- +-- Family E has no nested dev yet, so both "give `wm-fork-edev` a dev" and "detach `wm-fork-edev`" +-- pass their own checks — the pair that must not both commit: +-- prod-e -> wm-fork-edev ('dev'), plus the standalone candidate `e-cand` +-- +-- Family F is three standalone workspaces, so "attach f-mid under prod-f" and "attach f-leaf under +-- f-mid" both pass on their own — adjacent attaches whose labels only collide once both land: +-- prod-f, f-mid, f-leaf +-- +-- Family G already nests, so two attaches at opposite ends of it touch no workspace in common — +-- their labels only collide once both land, three dev workspaces deep: +-- prod-g (root), g-mid -> g-sub ('dev'), and the standalone `g-leaf` +-- +-- Family H is a standalone that already owns a dev: archiving it resolves as "no pairing involved" +-- while still being an operation the pairing lock has to cover: +-- h-cand -> h-sub ('dev') + + +INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) VALUES + ('tw-dev', 'dev of test-workspace', 'test@windmill.dev', 'test-workspace', true, 'dev'), + ('standalone', 'standalone', 'test@windmill.dev', NULL, false, NULL), + ('standalone-dev', 'dev of standalone', 'test@windmill.dev', 'standalone', true, 'dev'), + ('spare', 'spare standalone', 'test@windmill.dev', NULL, false, NULL), + ('prod-b', 'prod b', 'test@windmill.dev', NULL, false, NULL), + ('wm-fork-redev', 'redesignated fork', 'test@windmill.dev', 'prod-b', true, 'dev'), + ('redev-dev', 'dev of the redesignated fork', 'test@windmill.dev', 'wm-fork-redev', true, 'staging'), + ('prod-c', 'prod c', 'test@windmill.dev', NULL, false, NULL), + ('c-dev', 'dev of prod-c', 'test@windmill.dev', 'prod-c', true, 'dev'), + ('c-dev-dev', 'dev of c-dev', 'test@windmill.dev', 'c-dev', true, 'staging'), + ('prod-e', 'prod e', 'test@windmill.dev', NULL, false, NULL), + ('wm-fork-edev', 'redesignated fork with no dev yet', 'test@windmill.dev', 'prod-e', true, 'dev'), + ('e-cand', 'attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('prod-f', 'prod f', 'test@windmill.dev', NULL, false, NULL), + ('f-mid', 'middle attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('f-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('prod-g', 'prod g', 'test@windmill.dev', NULL, false, NULL), + ('g-mid', 'standalone with a dev of its own', 'test@windmill.dev', NULL, false, NULL), + ('g-sub', 'dev of g-mid', 'test@windmill.dev', 'g-mid', true, 'dev'), + ('g-leaf', 'leaf attach candidate', 'test@windmill.dev', NULL, false, NULL), + ('h-cand', 'standalone owning a dev', 'test@windmill.dev', NULL, false, NULL), + ('h-sub', 'dev of h-cand', 'test@windmill.dev', 'h-cand', true, 'dev'); + +CREATE TEMP VIEW new_workspaces AS SELECT unnest(ARRAY[ + 'tw-dev', 'standalone', 'standalone-dev', 'spare', 'prod-b', 'wm-fork-redev', 'redev-dev', + 'prod-c', 'c-dev', 'c-dev-dev', 'prod-e', 'wm-fork-edev', 'e-cand', + 'prod-f', 'f-mid', 'f-leaf', 'prod-g', 'g-mid', 'g-sub', 'g-leaf', + 'h-cand', 'h-sub' +]) AS id; + +INSERT INTO workspace_settings (workspace_id) + SELECT id FROM new_workspaces; + +INSERT INTO workspace_key (workspace_id, kind, key) + SELECT id, 'cloud', 'test-key' FROM new_workspaces; + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) + SELECT id, 'all', 'All users', '{}' FROM new_workspaces; + +INSERT INTO usr (workspace_id, email, username, is_admin, role) + SELECT id, 'test@windmill.dev', 'test-user', true, 'Admin' + FROM new_workspaces; + +DROP VIEW new_workspaces; diff --git a/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs new file mode 100644 index 0000000000..a9549b91a8 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs @@ -0,0 +1,444 @@ +//! A dev workspace may itself be paired with one (a "dev of a dev"). The guards that keep that +//! shape well-formed are what these tests pin: a chain must stay acyclic, and no two dev workspaces +//! in it may carry the same environment label — they inherit the same git-sync repositories, so an +//! equal label means both deploy to one branch. + +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; + +async fn attach(port: u16, prod: &str, body: serde_json::Value) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{prod}/workspaces/attach_dev_workspace" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .json(&body) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +async fn detach(port: u16, prod: &str, dev: &str) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{prod}/workspaces/detach_dev_workspace" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .json(&json!({ "dev_workspace_id": dev })) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_nested_dev_workspace_attach_guards(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The family root is an ancestor of the dev workspace: reparenting it below would close a + // parent<->child cycle and hang every hierarchy walk. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "test-workspace", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_client_error(), + "cycle attach returned {status}: {body}" + ); + assert!(body.contains("ancestor"), "unexpected error: {body}"); + + // `tw-dev` is itself the 'dev' workspace of the root, so a dev nested under it cannot be one too. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "spare", "dev_workspace_label": "dev" }), + ) + .await; + assert!( + status.is_client_error(), + "label reuse returned {status}: {body}" + ); + assert!(body.contains("tw-dev"), "unexpected error: {body}"); + + // `standalone` brings its own 'dev'-labelled dev workspace into the chain, which collides with + // `tw-dev` whatever label the candidate itself is given. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "standalone", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_client_error(), + "subtree label reuse returned {status}: {body}" + ); + assert!(body.contains("standalone-dev"), "unexpected error: {body}"); + + // With a free label and nothing conflicting underneath, the nested pairing goes through. + let (status, body) = attach( + port, + "tw-dev", + json!({ "dev_workspace_id": "spare", "dev_workspace_label": "staging" }), + ) + .await; + assert!( + status.is_success(), + "nested attach returned {status}: {body}" + ); + // Runtime-checked (not `query!`): a macro here would need its own `.sqlx` entry, which + // `cargo sqlx prepare --workspace` does not produce for test targets. + let (parent, is_dev, label): (Option, bool, Option) = sqlx::query_as( + "SELECT parent_workspace_id, is_dev_workspace, dev_workspace_label FROM workspace WHERE id = 'spare'", + ) + .fetch_one(&db) + .await?; + assert_eq!(parent.as_deref(), Some("tw-dev")); + assert!(is_dev); + assert_eq!(label.as_deref(), Some("staging")); + + // A chain runs as deep as there are distinct labels to give it, not two: + // `test-workspace` -> `tw-dev` ('dev') -> `spare` ('staging') -> `e-cand` ('uat'). + let (status, body) = attach( + port, + "spare", + json!({ "dev_workspace_id": "e-cand", "dev_workspace_label": "uat" }), + ) + .await; + assert!( + status.is_success(), + "third-label attach returned {status}: {body}" + ); + + Ok(()) +} + +async fn archive(port: u16, w_id: &str) -> (reqwest::StatusCode, String) { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{w_id}/workspaces/archive" + )) + .header("Authorization", format!("Bearer {ADMIN_TOKEN}")) + .send() + .await + .unwrap(); + let status = resp.status(); + (status, resp.text().await.unwrap()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_teardown_refuses_to_strand_a_nested_dev(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // A `wm-fork-` workspace keeps its parent when it stops being a dev workspace, so it returns to + // being a throwaway fork — which hosts no pairing, leaving `redev-dev` attached with no way to + // reach it. Detach and archive both clear the flag, so both have to refuse. + let (status, body) = detach(port, "prod-b", "wm-fork-redev").await; + assert!( + status.is_client_error(), + "stranding detach returned {status}: {body}" + ); + assert!(body.contains("redev-dev"), "unexpected error: {body}"); + + let (status, body) = archive(port, "wm-fork-redev").await; + assert!( + status.is_client_error(), + "stranding archive returned {status}: {body}" + ); + assert!(body.contains("redev-dev"), "unexpected error: {body}"); + + // Archive soft-deletes whatever the id looks like, so a prefix-less dev workspace strands its + // own dev too — even though detaching that same workspace is fine (it returns to standalone). + let (status, body) = archive(port, "c-dev").await; + assert!( + status.is_client_error(), + "prefix-less stranding archive returned {status}: {body}" + ); + assert!(body.contains("c-dev-dev"), "unexpected error: {body}"); + let (status, body) = detach(port, "prod-c", "c-dev").await; + assert!( + status.is_success(), + "prefix-less detach returned {status}: {body}" + ); + + // Bottom-up is the supported order. + let (status, body) = detach(port, "wm-fork-redev", "redev-dev").await; + assert!(status.is_success(), "leaf detach returned {status}: {body}"); + let (status, body) = detach(port, "prod-b", "wm-fork-redev").await; + assert!( + status.is_success(), + "detach after cleanup returned {status}: {body}" + ); + + Ok(()) +} + +/// Giving a workspace a dev and clearing its own dev flag each decide on state the other mutates, so +/// checked outside a common lock both commit and leave `e-cand` under a throwaway fork. Fired +/// together: whichever lands second must see the first and be rejected. +/// +/// Repeated, because how far each handler gets before the other's mutation lands is timing-dependent +/// — one pass caught an unlocked build only about a fifth of the time, and the runs are cheap. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_nested_attach_and_detach_cannot_both_commit( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + // Back to `prod-e -> wm-fork-edev ('dev')` with `e-cand` standalone, the state in which both + // requests pass their own checks. + sqlx::query( + "UPDATE workspace SET parent_workspace_id = 'prod-e', is_dev_workspace = true, + dev_workspace_label = 'dev' WHERE id = 'wm-fork-edev'", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id = 'e-cand'", + ) + .execute(&db) + .await?; + + let (attached, detached) = tokio::join!( + attach( + port, + "wm-fork-edev", + json!({ "dev_workspace_id": "e-cand", "dev_workspace_label": "staging" }), + ), + detach(port, "prod-e", "wm-fork-edev"), + ); + assert!( + attached.0.is_success() != detached.0.is_success(), + "round {round}: exactly one must win, got attach={} detach={}\n{}\n{}", + attached.0, + detached.0, + attached.1, + detached.1 + ); + + // Whichever won, `wm-fork-edev` is never left a throwaway fork with a dev workspace beneath it. + let (is_dev, cand_parent): (bool, Option) = sqlx::query_as( + "SELECT (SELECT is_dev_workspace FROM workspace WHERE id = 'wm-fork-edev'), + (SELECT parent_workspace_id FROM workspace WHERE id = 'e-cand')", + ) + .fetch_one(&db) + .await?; + assert!( + is_dev || cand_parent.is_none(), + "round {round}: stranded — wm-fork-edev is_dev={is_dev}, e-cand parent={cand_parent:?}" + ); + } + Ok(()) +} + +/// Two adjacent attaches — `f-mid` under `prod-f` and `f-leaf` under `f-mid` — each see a chain that +/// does not yet contain the other's dev workspace, so both pass their label check. Committing both +/// puts two `dev` workspaces in one chain, deploying to the same branch. Repeated for the same +/// reason as the detach race above. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_adjacent_attaches_cannot_both_claim_a_label( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id IN ('f-mid', 'f-leaf')", + ) + .execute(&db) + .await?; + + let (upper, lower) = tokio::join!( + attach( + port, + "prod-f", + json!({ "dev_workspace_id": "f-mid", "dev_workspace_label": "dev" }), + ), + attach( + port, + "f-mid", + json!({ "dev_workspace_id": "f-leaf", "dev_workspace_label": "dev" }), + ), + ); + assert!( + upper.0.is_success() != lower.0.is_success(), + "round {round}: exactly one must win, got upper={} lower={}\n{}\n{}", + upper.0, + lower.0, + upper.1, + lower.1 + ); + + // Never both: that is the chain prod-f -> f-mid('dev') -> f-leaf('dev'). + let chained: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM workspace mid + JOIN workspace leaf ON leaf.parent_workspace_id = mid.id + WHERE mid.id = 'f-mid' AND leaf.id = 'f-leaf' + AND mid.is_dev_workspace AND leaf.is_dev_workspace + AND mid.parent_workspace_id = 'prod-f' + )", + ) + .fetch_one(&db) + .await?; + assert!(!chained, "round {round}: both attaches committed"); + } + Ok(()) +} + +/// Attaching `g-mid` under `prod-g` and attaching `g-leaf` under `g-sub` touch no workspace in +/// common — `g-sub` already sits under `g-mid`, so the two operations are two hops apart. Each sees +/// a two-workspace chain with a free label; together they make a four-deep one that repeats +/// `staging`. Locking the endpoints alone leaves them free to both commit, which is why the pairing +/// lock covers every workspace its checks read. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_attaches_two_hops_apart_cannot_both_claim_a_label( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for round in 0..12 { + sqlx::query( + "UPDATE workspace SET parent_workspace_id = NULL, is_dev_workspace = false, + dev_workspace_label = NULL WHERE id IN ('g-mid', 'g-leaf')", + ) + .execute(&db) + .await?; + + let (upper, lower) = tokio::join!( + attach( + port, + "prod-g", + json!({ "dev_workspace_id": "g-mid", "dev_workspace_label": "staging" }), + ), + attach( + port, + "g-sub", + json!({ "dev_workspace_id": "g-leaf", "dev_workspace_label": "staging" }), + ), + ); + assert!( + upper.0.is_success() != lower.0.is_success(), + "round {round}: exactly one must win, got upper={} lower={}\n{}\n{}", + upper.0, + lower.0, + upper.1, + lower.1 + ); + + // No chain may carry one label twice. Walk every dev workspace up to its root and count. + let duplicated: Option = sqlx::query_scalar( + "WITH RECURSIVE chain AS ( + SELECT id AS leaf, id, parent_workspace_id, is_dev_workspace, + COALESCE(dev_workspace_label, 'dev') AS label, 0 AS depth + FROM workspace WHERE is_dev_workspace AND NOT deleted + UNION ALL + SELECT c.leaf, w.id, w.parent_workspace_id, w.is_dev_workspace, + COALESCE(w.dev_workspace_label, 'dev'), c.depth + 1 + FROM workspace w JOIN chain c ON w.id = c.parent_workspace_id + WHERE c.depth < 20 + ) + SELECT leaf FROM chain WHERE is_dev_workspace + GROUP BY leaf, label HAVING count(*) > 1 LIMIT 1", + ) + .fetch_optional(&db) + .await?; + assert!( + duplicated.is_none(), + "round {round}: a chain repeats a label, below {duplicated:?}" + ); + } + Ok(()) +} + +/// The pairing lock covers the chains an operation touches, not every pairing on the instance: a +/// transaction holding one family's nodes must not hold up another family's. Pinned because the +/// obvious way to make the races above safe — one key for the whole operation class — would serialize +/// dev-workspace creation database-wide, and creation holds its transaction across a full clone. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_pairing_lock_does_not_span_unrelated_families( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Hold family F's nodes the way an in-flight attach on it would, then act on family E. + let mut held = db.begin().await?; + for node in ["prod-f", "f-mid", "f-leaf"] { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))") + .bind(node) + .execute(&mut *held) + .await?; + } + // Also the un-suffixed key. Nothing takes it today, so holding it costs the passing case + // nothing — but a lock narrowed back to one key for every family would take it, and without + // this the test would sail through that exact regression. + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing'))") + .execute(&mut *held) + .await?; + + let (status, body) = tokio::time::timeout( + std::time::Duration::from_secs(20), + detach(port, "prod-e", "wm-fork-edev"), + ) + .await + .map_err(|_| anyhow::anyhow!("an unrelated family's pairing blocked on family F's locks"))?; + assert!( + status.is_success(), + "unrelated detach returned {status}: {body}" + ); + + held.rollback().await?; + Ok(()) +} + +/// Archive resolves the workspace's pairing state before its transaction, so it takes the pairing +/// lock before reading that state again rather than on the strength of it — an attach can be turning +/// the workspace into a dev in the meantime. `h-cand` is standalone, the shape whose resolved state +/// says no pairing is involved: its archive must wait on the lock all the same. +#[sqlx::test(migrations = "../migrations", fixtures("base", "nested_dev_workspace"))] +async fn test_archive_takes_the_pairing_lock_for_a_standalone_workspace( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let mut held = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))") + .bind("h-cand") + .execute(&mut *held) + .await?; + + let finished = + tokio::time::timeout(std::time::Duration::from_secs(5), archive(port, "h-cand")).await; + assert!( + finished.is_err(), + "archive of a standalone workspace completed while its pairing lock was held: {finished:?}" + ); + + held.rollback().await?; + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 475c8db285..3cfdc96996 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -490,8 +490,8 @@ struct CreateWorkspaceFork { /// the team can work in it. Defaults off; the dev-workspace UI defaults it on. #[serde(default)] copy_members: bool, - /// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text + - /// wording); ignored for non-dev forks. None defaults to 'dev'. + /// Environment label for the dev workspace, e.g. 'dev' or 'staging': its badge text and the + /// branch it deploys to. Ignored for non-dev forks. None defaults to 'dev'. #[serde(default)] dev_workspace_label: Option, } @@ -712,16 +712,84 @@ struct DevWorkspaceInfo { dev_workspace_label: Option, } -/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev"; -/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it -/// round-trips, but a NULL column is treated as 'dev' on the read side too. +/// The environment labels a dev workspace may carry, ordered dev -> prod. Each names the git branch +/// that workspace deploys to (`dev_workspace_branch`), and every dev workspace in a chain must +/// carry a distinct one (`reject_dev_label_taken_in_chain`) — so the length of this list is also +/// the deepest promotion chain. A fixed list rather than free text: the label has to be a usable +/// single-segment branch name, must not collide with the `wm-fork/**` and `wm_deploy/**` namespaces +/// git-sync already writes, and must not be a repository's default branch (`main`, `master`). +pub const DEV_WORKSPACE_LABELS: [&str; 8] = [ + "dev", "qa", "test", "uat", "staging", "demo", "sandbox", "preprod", +]; + +/// Normalize/validate the dev-workspace environment label. Unset defaults to 'dev', which is also +/// what a NULL column reads as; any supplied value must be one of `DEV_WORKSPACE_LABELS` exactly, +/// so the accepted set is what the OpenAPI enum advertises — no trimming, no empty-string alias. fn normalize_dev_workspace_label(label: Option) -> Result> { - match label.as_deref() { - None | Some("dev") => Ok(Some("dev".to_string())), - Some("staging") => Ok(Some("staging".to_string())), - Some(other) => Err(Error::BadRequest(format!( - "invalid dev workspace label '{other}' (expected 'dev' or 'staging')" - ))), + let Some(label) = label else { + return Ok(Some("dev".to_string())); + }; + if !DEV_WORKSPACE_LABELS.contains(&label.as_str()) { + return Err(Error::BadRequest(format!( + "invalid dev workspace label '{label}' (expected one of: {})", + DEV_WORKSPACE_LABELS.join(", ") + ))); + } + Ok(Some(label)) +} + +#[cfg(test)] +mod dev_workspace_label_tests { + use super::{normalize_dev_workspace_label, tracked_branch_blocks_dev_label}; + + #[test] + fn tracked_branch_blocks_its_own_name_and_its_namespace() { + assert!(tracked_branch_blocks_dev_label("uat", "uat")); + // The label would have to be a ref and a ref directory at once. + assert!(tracked_branch_blocks_dev_label("release", "release/main")); + assert!(!tracked_branch_blocks_dev_label("release", "release-main")); + assert!(!tracked_branch_blocks_dev_label("release", "main")); + assert!(!tracked_branch_blocks_dev_label("uat", "pre/uat")); + } + + fn norm(label: &str) -> Option { + normalize_dev_workspace_label(Some(label.to_string())) + .ok() + .flatten() + } + + #[test] + fn unset_defaults_to_dev() { + assert_eq!( + normalize_dev_workspace_label(None).unwrap().as_deref(), + Some("dev") + ); + } + + #[test] + fn accepts_every_offered_label_and_nothing_else() { + for label in super::DEV_WORKSPACE_LABELS { + assert_eq!(norm(label).as_deref(), Some(label), "rejected '{label}'"); + } + // Off-list names are refused whether or not they would make a usable branch: the list is + // what keeps a label off `main`/`master` and out of the `wm-fork/**` and `wm_deploy/**` + // namespaces git-sync writes. Padded and empty values are refused too, so the accepted set + // is exactly the OpenAPI enum rather than a superset a validating client would reject. + for label in [ + " uat ", + "", + "main", + "master", + "wm-fork", + "wm_deploy", + "UAT", + "feature/uat", + ] { + assert!( + normalize_dev_workspace_label(Some(label.to_string())).is_err(), + "accepted '{label}'" + ); + } } } @@ -795,11 +863,23 @@ fn clear_client_supplied_auto_pull_state( auto_pull.last_pull_status = None; } -/// A dev workspace deploys to a branch named after its environment label. If a -/// git-sync repository's tracked branch carries that same name, dev deploys -/// would write straight into the branch the workspace (or its prod) syncs -/// from — the CLI refuses that push, so every deploy job would fail. Reject -/// the label up front instead. +/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's +/// deploy branch. Two ways it can: +/// +/// - the same name: dev deploys would write straight into the branch the workspace (or its prod) +/// syncs from, and the CLI refuses that push; +/// - `label_branch` is the namespace `tracked` sits under (label `release`, tracked +/// `release/main`): git stores refs hierarchically, so `refs/heads/release` cannot exist +/// alongside `refs/heads/release/main`. +/// +/// Either way every deploy job from that workspace would fail. +fn tracked_branch_blocks_dev_label(label_branch: &str, tracked: &str) -> bool { + tracked == label_branch || tracked.starts_with(&format!("{label_branch}/")) +} + +/// Reject a label whose branch clashes with a tracked branch of any git-sync repository on +/// `workspace_ids`, before the pairing is created. (`wm-fork` and `wm_deploy`, whose namespaces +/// exist whatever a repo tracks, are reserved unconditionally in `normalize_dev_workspace_label`.) async fn reject_dev_label_matching_tracked_branch( db: &DB, label: Option<&str>, @@ -827,14 +907,31 @@ async fn reject_dev_label_matching_tracked_branch( .fetch_optional(db) .await? .flatten(); - if branch.as_deref() == Some(label_branch.as_str()) { - return Err(Error::BadRequest(format!( + // A repository that pins no branch tracks the remote's default, which cannot be + // resolved here without a network call — but no offered label is a plausible default + // (`main`/`master` are off the list), so there is nothing to compare against. + let Some(tracked) = branch.as_deref().filter(|b| !b.is_empty()) else { + continue; + }; + if !tracked_branch_blocks_dev_label(&label_branch, tracked) { + continue; + } + return Err(Error::BadRequest(if tracked == label_branch { + format!( "The environment label '{label_branch}' matches the tracked branch of git-sync \ repository '{path}' in workspace '{w_id}': deploys from the dev workspace go \ to the '{label_branch}' branch and would overwrite the branch that repository \ - syncs from. Use the other label or change the repository's tracked branch." - ))); - } + syncs from. Use a different label or change the repository's branch." + ) + } else { + format!( + "The environment label '{label_branch}' is the namespace of the tracked branch \ + '{tracked}' of git-sync repository '{path}' in workspace '{w_id}': git cannot \ + hold a branch named '{label_branch}' alongside '{tracked}', so every deploy \ + from the dev workspace would fail. Use a different label or change the \ + repository's branch." + ) + })); } } Ok(()) @@ -4910,11 +5007,17 @@ struct SessionWorkspaceStatusRequest { /// Reconciliation support for client-side AI sessions, which the backend cannot touch /// directly. The client posts the workspace ids its sessions reference and uses the -/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row / -/// no access → unresolvable) drops the sessions, `archived` (soft-deleted, still a -/// member) archives them, `active` restores ones previously archived-by-workspace. +/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row, or +/// no way for this caller to reach it) drops the sessions, `archived` (soft-deleted, still +/// reachable) archives them, `active` restores ones previously archived-by-workspace. /// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the /// only way the client learns about a change made while it was away or on another device. +/// +/// Membership alone under-reports reachability: a superadmin is authed into any existing +/// workspace without a `usr` row, and `admins` has no `usr` rows at all, so answering from +/// `usr` destroys sessions that still work. It over-reports in one direction — a `usr` row +/// with `disabled` counts here but not in the extractor — which only leaves a session +/// lingering, so it is deliberately not treated as unreachable. async fn session_workspace_status( Extension(db): Extension, ApiAuthed { email, .. }: ApiAuthed, @@ -4925,10 +5028,15 @@ async fn session_workspace_status( "Too many workspace ids (max 1000)".to_string(), )); } + let is_superadmin = windmill_common::auth::is_super_admin_email(&db, &email).await?; let rows = sqlx::query!( + // A missing workspace row must be caught before the membership arm: for a + // superadmin the two arms below both fall through, and a hard-deleted workspace + // would report `active` forever. "SELECT req.id AS \"id!\", (CASE - WHEN usr.email IS NULL THEN 'deleted' + WHEN workspace.id IS NULL THEN 'deleted' + WHEN usr.email IS NULL AND NOT $3 THEN 'deleted' WHEN workspace.deleted THEN 'archived' ELSE 'active' END) AS \"status!\" @@ -4937,6 +5045,7 @@ async fn session_workspace_status( LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2", &req.workspace_ids[..], email, + is_superadmin, ) .fetch_all(&db) .await?; @@ -6687,10 +6796,18 @@ async fn create_workspace_fork_branch( // that second call. Validating early lets a bad request fail before any branch is created. if nw.is_dev_workspace { validate_dev_workspace_id(&nw.id)?; - // Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork). + // Reject a bad label before any git branch is created (acted on in create_workspace_fork). let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&w_id]).await?; - ensure_dev_parent_is_root(&db, &w_id).await?; + ensure_dev_parent_can_host_dev(&db, &w_id).await?; + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &w_id, + &nw.id, + false, + label.as_deref(), + ) + .await?; // Reject before creating any git branch if the parent already has a dev workspace, // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. ensure_no_existing_dev_workspace(&db, &w_id).await?; @@ -7006,11 +7123,19 @@ async fn create_workspace_fork( validate_fork_workspace_id(&nw.id)?; } validate_workspace_name(&nw.name)?; - // Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL. + // The environment label only applies to dev workspaces; a non-dev fork stores NULL. let dev_workspace_label = if nw.is_dev_workspace { let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&parent_workspace_id]) .await?; + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &parent_workspace_id, + &nw.id, + false, + label.as_deref(), + ) + .await?; label } else { None @@ -7077,7 +7202,7 @@ async fn create_workspace_fork( } if nw.is_dev_workspace { - ensure_dev_parent_is_root(&db, &parent_workspace_id).await?; + ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?; // Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod // mutates its protection rules), so require admin of the parent regardless of the lock flags — // mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could @@ -7088,6 +7213,22 @@ async fn create_workspace_fork( let mut tx: Transaction<'_, Postgres> = db.begin().await?; + if nw.is_dev_workspace { + // The checks above ran outside a transaction, so the parent's eligibility and the chain's + // labels could have changed under us: re-decide both here, under the pairing lock. + lock_dev_pairing(&mut tx, &[&parent_workspace_id]).await?; + ensure_dev_parent_can_host_dev(&mut *tx, &parent_workspace_id).await?; + ensure_no_existing_dev_workspace(&mut *tx, &parent_workspace_id).await?; + reject_dev_label_taken_in_chain( + &mut *tx, + &parent_workspace_id, + &nw.id, + false, + dev_workspace_label.as_deref(), + ) + .await?; + } + let forked_id = nw.id; sqlx::query!( @@ -7264,7 +7405,7 @@ struct AttachDevWorkspace { lock_prod_deploy: bool, #[serde(default)] lock_prod_forking: bool, - /// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'. + /// Environment label for the attached dev workspace, e.g. 'dev' or 'staging'. None defaults to 'dev'. #[serde(default)] dev_workspace_label: Option, } @@ -7321,15 +7462,6 @@ async fn attach_dev_workspace( // The id is interpolated into a `wm-fork//` branch name like any fork. validate_dev_workspace_id(&dev_w_id)?; let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?; - // The attached workspace keeps its own sync repos and prod keeps its config; - // the label branch must not collide with either side's tracked branch. - reject_dev_label_matching_tracked_branch( - &db, - dev_workspace_label.as_deref(), - &[&prod_w_id, &dev_w_id], - ) - .await?; - let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, &dev_w_id @@ -7358,24 +7490,41 @@ async fn attach_dev_workspace( dev_w_id ))); } - // The candidate can't itself be a prod with its own dev workspace (no nested dev chains). - ensure_no_existing_dev_workspace(&db, &dev_w_id).await?; - - // Prod must be a root workspace, otherwise attaching could form a parent<->child cycle (e.g. - // attaching A as the dev of B when B is already the dev of A), which breaks hierarchy traversal. - let prod_has_parent = sqlx::query_scalar!( - r#"SELECT (parent_workspace_id IS NOT NULL) AS "has_parent!" FROM workspace WHERE id = $1"#, + let prod_exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS "exists!""#, &prod_w_id ) - .fetch_optional(&db) - .await? - .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", prod_w_id)))?; - if prod_has_parent { - return Err(Error::BadRequest(format!( - "Workspace {} is itself a fork or dev workspace and cannot be a prod workspace", + .fetch_one(&db) + .await?; + if !prod_exists { + return Err(Error::NotFound(format!( + "Workspace {} not found", prod_w_id ))); } + // Prod may be a root workspace or another dev workspace (a dev of a dev); a throwaway fork + // can't host one. + ensure_dev_parent_can_host_dev(&db, &prod_w_id).await?; + reject_attach_cycle(&db, &prod_w_id, &dev_w_id).await?; + + // The attached workspace keeps its own sync repos and prod keeps its config; the label branch + // must not collide with either side's tracked branch. + reject_dev_label_matching_tracked_branch( + &db, + dev_workspace_label.as_deref(), + &[&prod_w_id, &dev_w_id], + ) + .await?; + // The candidate keeps its own subtree, so its dev descendants keep their labels and join the + // chain alongside it. + reject_dev_label_taken_in_chain( + &mut *db.acquire().await?, + &prod_w_id, + &dev_w_id, + true, + dev_workspace_label.as_deref(), + ) + .await?; // The caller must be admin of the dev workspace too (or a superadmin). let is_admin_of_dev = sqlx::query_scalar!( @@ -7392,9 +7541,47 @@ async fn attach_dev_workspace( ))); } - ensure_no_existing_dev_workspace(&db, &prod_w_id).await?; - let mut tx = db.begin().await?; + // Everything above ran outside a transaction, so prod's eligibility and the chain's labels could + // have changed under us: re-decide both here, under the pairing lock. + lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; + // The candidate was read before the lock, and archiving it is one of the operations the lock + // serializes: re-read it, or the pairing lands on a workspace that is gone or has since been + // taken by another prod. + let dev = sqlx::query!( + r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, + &dev_w_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?; + if dev.deleted { + return Err(Error::BadRequest(format!( + "Workspace {} is archived", + dev_w_id + ))); + } + if dev + .parent_workspace_id + .as_deref() + .is_some_and(|p| p != prod_w_id) + { + return Err(Error::BadRequest(format!( + "Workspace {} is already a fork or dev workspace of another workspace", + dev_w_id + ))); + } + ensure_dev_parent_can_host_dev(&mut *tx, &prod_w_id).await?; + reject_attach_cycle(&mut *tx, &prod_w_id, &dev_w_id).await?; + ensure_no_existing_dev_workspace(&mut *tx, &prod_w_id).await?; + reject_dev_label_taken_in_chain( + &mut *tx, + &prod_w_id, + &dev_w_id, + true, + dev_workspace_label.as_deref(), + ) + .await?; sqlx::query!( "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", &prod_w_id, @@ -7566,6 +7753,11 @@ async fn detach_dev_workspace( require_admin(authed.is_admin, &authed.username)?; let dev_w_id = req.dev_workspace_id; + + let mut tx = db.begin().await?; + // Under the pairing lock, so a dev workspace cannot appear beneath this one between the check + // below and the update. + lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?; let is_dev_of_prod = sqlx::query_scalar!( r#"SELECT EXISTS( SELECT 1 FROM workspace @@ -7574,7 +7766,7 @@ async fn detach_dev_workspace( &dev_w_id, &prod_w_id ) - .fetch_one(&db) + .fetch_one(&mut *tx) .await? .unwrap_or(false); if !is_dev_of_prod { @@ -7583,8 +7775,8 @@ async fn detach_dev_workspace( dev_w_id, prod_w_id ))); } + reject_stranding_nested_dev(&mut *tx, &dev_w_id, DevTeardown::Detach).await?; - let mut tx = db.begin().await?; // A wm-fork- workspace re-designated as dev returns to being a plain fork // (keeps its parent); a standalone workspace that was attached returns to // being standalone — with the parent kept it would still classify as a @@ -7692,6 +7884,30 @@ pub(crate) async fn archive_workspace_impl( ) -> Result<(usize, usize, usize)> { // Step 1: Disable all schedules and clear their queued jobs let mut tx = db.begin().await?; + // Unconditionally, before reading any pairing state: whether this workspace is a dev, and whether + // it has one, is exactly what a concurrent attach changes, so gating the lock on the caller's + // `dev_lock_parent` would skip it on the strength of the value the race invalidates. + lock_dev_pairing(&mut tx, &[w_id]).await?; + let dev_parent = sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace", + w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + // The caller resolved this before the lock and authorized against it — its prod admin check, and + // the pairing teardown below, are both answers to that value. Refuse rather than act on a pairing + // nobody checked. + if dev_parent.as_deref() != dev_lock_parent { + return Err(Error::BadRequest(format!( + "The dev pairing of {w_id} changed while it was being archived. Retry." + ))); + } + if dev_parent.is_some() { + // Archiving a dev workspace clears its dev flag, so it must not strand a dev workspace of + // its own. + reject_stranding_nested_dev(&mut *tx, w_id, DevTeardown::Archive).await?; + } let disabled_schedules = sqlx::query_scalar!( "UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path", w_id @@ -7830,7 +8046,9 @@ async fn archive_workspace( } // The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's - // transaction, atomically with `deleted = true`. + // transaction, atomically with `deleted = true` — including the guard that it strands no nested + // dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a + // root archived out from under its dev is the pre-existing shape and not this pairing's to police. let (schedules_count, canceled_count, deleted_tokens_count) = archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?; @@ -8885,7 +9103,10 @@ async fn lock_prod_workspace( /// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the /// partial unique index `workspace_canonical_dev_idx` with a friendly message. -async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result<()> { +async fn ensure_no_existing_dev_workspace<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + parent_w_id: &str, +) -> Result<()> { let existing = sqlx::query_scalar!( "SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", parent_w_id @@ -8901,22 +9122,263 @@ async fn ensure_no_existing_dev_workspace(db: &DB, parent_w_id: &str) -> Result< Ok(()) } -/// A dev workspace pairs with a root prod workspace; nesting dev workspaces (a dev of a dev) isn't -/// supported and would muddle the prod<->dev relationship. -async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> { - let parent_is_fork = sqlx::query_scalar!( - r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#, +/// A dev workspace pairs with a root workspace or — supported, though not the recommended shape — +/// with another dev workspace, giving a promotion chain (dev of dev -> dev -> prod). A throwaway +/// fork is never a valid prod: its deploys go to its own `wm-fork/**` branch and it is discarded +/// with its subtree, so a dev pinned under it has nowhere to promote to. Nor is an archived one, +/// which hosts nothing at all. +async fn ensure_dev_parent_can_host_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + parent_w_id: &str, +) -> Result<()> { + let parent = sqlx::query!( + r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!", is_dev_workspace, deleted + FROM workspace WHERE id = $1"#, parent_w_id ) .fetch_optional(db) - .await? - .unwrap_or(false); - if parent_is_fork { + .await?; + let Some(parent) = parent else { + return Ok(()); + }; + if parent.deleted { return Err(Error::BadRequest(format!( - "Cannot create a dev workspace of '{}' because it is itself a fork or dev workspace.", + "Cannot create a dev workspace of '{}' because it is archived.", parent_w_id ))); } + if parent.is_fork && !parent.is_dev_workspace { + return Err(Error::BadRequest(format!( + "Cannot create a dev workspace of '{}' because it is a throwaway fork.", + parent_w_id + ))); + } + Ok(()) +} + +/// Prod may be a dev workspace, so the candidate can sit ABOVE it in the tree — reparenting it below +/// prod would close a parent<->child cycle and hang every hierarchy walk. Prod itself is at depth 0 +/// of the chain, so callers must have rejected `dev_w_id == prod_w_id` first. +/// +/// `reject_dev_label_taken_in_chain` would also reject a cyclic pairing, since a cycle puts one +/// workspace in the chain twice and so always repeats a label. It reports it as a workspace clashing +/// with itself, which describes nothing the caller can act on — hence this, first. +async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + prod_w_id: &str, + dev_w_id: &str, +) -> Result<()> { + let would_cycle = sqlx::query_scalar!( + r#"WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w + JOIN chain ON w.id = chain.parent_workspace_id + WHERE chain.depth < 20 + ) + SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS "cycle!""#, + prod_w_id, + dev_w_id, + ) + .fetch_one(db) + .await?; + if would_cycle { + return Err(Error::BadRequest(format!( + "Workspace {} is an ancestor of {} and cannot become its dev workspace", + dev_w_id, prod_w_id + ))); + } + Ok(()) +} + +/// Serialize everything that makes or breaks a dev pairing: giving a workspace a dev workspace +/// (create, attach) and clearing one's dev flag (detach, archive). Each decides on state the others +/// mutate — whether a workspace already has a dev, still has one, or leaves a label free — so +/// unserialized they all pass their checks and commit a shape those checks exist to reject. Take it +/// before re-running them inside the mutating transaction; it releases on commit or rollback. +/// +/// Locks every workspace the operation's own checks read: each seed, its ancestors, and the dev +/// workspaces beneath it. Locking just the endpoints is not enough — the label rule spans a whole +/// chain, so two operations a couple of hops apart would hold disjoint keys and both commit. Reading +/// the same set that is checked is what closes that: an attach splices two chains together and so +/// holds nodes from both, and any operation that could collide with it necessarily touches the +/// joined chain, hence shares a node. Acquired in id order, the only ordering rule that keeps two +/// overlapping sets from deadlocking. +/// +/// Recomputed inside the transaction, but from a set that may already be stale — harmless, because +/// whoever made it stale is the operation holding the node this one is missing. +pub(crate) async fn lock_dev_pairing( + tx: &mut Transaction<'_, Postgres>, + seeds: &[&str], +) -> Result<()> { + let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); + // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. + let nodes = sqlx::query_scalar!( + r#"WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id), + up AS ( + SELECT w.id, w.parent_workspace_id, 0 AS depth + FROM workspace w JOIN seeded s ON w.id = s.id + UNION ALL + SELECT w.id, w.parent_workspace_id, up.depth + 1 + FROM workspace w JOIN up ON w.id = up.parent_workspace_id + WHERE up.depth < 20 + ), + down AS ( + SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id + UNION ALL + SELECT w.id, down.depth + 1 + FROM workspace w JOIN down ON w.parent_workspace_id = down.id + WHERE down.depth < 20 AND w.is_dev_workspace + ) + SELECT id AS "id!" FROM ( + SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down + ) n ORDER BY id"#, + &seeds[..] + ) + .fetch_all(&mut **tx) + .await?; + // One statement per node rather than a set-returning call: only a client-side loop actually + // guarantees the acquisition order the deadlock argument above rests on. + for node in nodes { + sqlx::query!( + "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))", + node + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// What is about to clear `is_dev_workspace` on a workspace, which decides whether the workspace can +/// go on hosting a dev workspace of its own afterwards. +enum DevTeardown { + /// Keeps `parent_workspace_id` only for a `wm-fork-` workspace, which then reads as a throwaway + /// fork. A prefix-less workspace returns to standalone and hosts its dev exactly as before. + Detach, + /// Soft-deletes the workspace whatever its id looks like, so it hosts nothing afterwards. + Archive, +} + +/// A nested dev workspace outlives whatever clears its parent's dev flag, and the parent is then a +/// shape that hosts no pairing: its settings tab offers no detach control, and `delete_workspace` +/// refuses a workspace that still has a dev child, so the pairing could never be undone. Reject the +/// teardown so it is done bottom-up instead. +async fn reject_stranding_nested_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>( + db: E, + w_id: &str, + teardown: DevTeardown, +) -> Result<()> { + let action = match teardown { + DevTeardown::Detach => { + if !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) { + return Ok(()); + } + "Detaching" + } + DevTeardown::Archive => "Archiving", + }; + let nested = sqlx::query_scalar!( + "SELECT id FROM workspace + WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted", + w_id + ) + .fetch_optional(db) + .await?; + if let Some(nested) = nested { + return Err(Error::BadRequest(format!( + "{action} {w_id} would leave it unable to host a pairing, but it is the prod workspace \ + of '{nested}'. Detach '{nested}' first." + ))); + } + Ok(()) +} + +/// A dev workspace deploys to the branch named by its environment label, and every dev workspace in +/// a chain inherits the same git-sync repositories, so two of them sharing a label push to one +/// branch: each deploy clobbers the other environment, and the root's auto-pull routes that branch +/// to whichever dev it matches first. Require every dev workspace in the resulting chain to carry a +/// distinct label — the dev ancestors `new_dev_id` lands under, `new_dev_id` with `label`, and (when +/// it already exists and so keeps its own subtree) the dev workspaces it brings with it. Dev +/// workspaces only ever hang off a root or another dev (`ensure_dev_parent_can_host_dev`), so that +/// chain is linear and this is the whole of it. +async fn reject_dev_label_taken_in_chain( + db: &mut sqlx::PgConnection, + parent_w_id: &str, + new_dev_id: &str, + keeps_own_subtree: bool, + label: Option<&str>, +) -> Result<()> { + // Depth bounds are the cycle-safety backstop used by every other hierarchy walk. + let mut chain = sqlx::query!( + r#"WITH RECURSIVE ancestors AS ( + SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted, + 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label, + w.deleted, ancestors.depth + 1 + FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id + WHERE ancestors.depth < 20 + ) + SELECT id AS "id!", dev_workspace_label FROM ancestors + WHERE is_dev_workspace AND NOT deleted"#, + parent_w_id + ) + .fetch_all(&mut *db) + .await? + .into_iter() + .map(|r| { + ( + r.id, + windmill_common::workspaces::dev_workspace_branch(r.dev_workspace_label.as_deref()), + ) + }) + .collect::>(); + chain.push(( + new_dev_id.to_string(), + windmill_common::workspaces::dev_workspace_branch(label), + )); + if keeps_own_subtree { + // `depth > 0`: the candidate itself is already in the list above, carrying its new label. + chain.extend( + sqlx::query!( + r#"WITH RECURSIVE tree AS ( + SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted, + tree.depth + 1 + FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id + WHERE tree.depth < 20 + ) + SELECT id AS "id!", dev_workspace_label FROM tree + WHERE depth > 0 AND is_dev_workspace AND NOT deleted"#, + new_dev_id + ) + .fetch_all(&mut *db) + .await? + .into_iter() + .map(|r| { + ( + r.id, + windmill_common::workspaces::dev_workspace_branch( + r.dev_workspace_label.as_deref(), + ), + ) + }), + ); + } + let mut by_branch: HashMap = HashMap::new(); + for (id, branch) in chain { + if let Some(other) = by_branch.insert(branch.clone(), id.clone()) { + return Err(Error::BadRequest(format!( + "'{other}' and '{id}' would both be '{branch}' workspaces in the same chain: dev \ + workspaces in a chain share their git-sync repositories, so both would deploy to \ + the '{branch}' branch. Use a different environment label." + ))); + } + } Ok(()) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index bcdffc6a2d..1968437e40 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -56,6 +56,12 @@ pub(crate) async fn change_workspace_id( let mut tx = db.begin().await?; + // A rename rewrites the workspace's dev flag and reparents its children, so it decides on the + // same state the pairing handlers do: without this lock a concurrent create/attach could commit + // an active dev workspace under the shell this rename is about to archive. Both ids, since the + // rename moves the chain from one to the other. + crate::workspaces::lock_dev_pairing(&mut tx, &[&old_id, &rw.new_id]).await?; + check_w_id_conflict(&mut tx, &rw.new_id).await?; info!( diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 4c410b7785..e39c2a35c4 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-amqp?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private", "windmill-object-store/private"] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-debug/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"] stripe = [] run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"] agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 834525994f..11beacd294 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.780.0 + version: 1.782.0 title: Windmill API contact: @@ -1239,7 +1239,8 @@ paths: type: boolean dev_workspace_label: type: string - enum: [dev, staging] + enum: [dev, qa, test, uat, staging, demo, sandbox, preprod] + description: "Environment label; also names the branch the dev workspace deploys to. Omitted defaults to 'dev'" required: - dev_workspace_id responses: @@ -1301,7 +1302,7 @@ paths: dev_workspace_label: type: string nullable: true - description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'" + description: "Environment label, e.g. 'dev' or 'staging'; null defaults to 'dev'" required: - id - name @@ -10698,6 +10699,33 @@ paths: schema: type: string + /w/{workspace}/jobs/job_public_view_token/{id}: + get: + summary: mint a public read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants anyone holding it — + including logged-out visitors, who land on the minimal public run page — read + access to this job (and its flow subtree) via a `view_token` query param or + `X-View-Token` header. Only callable by a user who can already read the job. + operationId: getJobPublicViewToken + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: the public share read token + content: + text/plain: + schema: + type: string + /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -31115,7 +31143,7 @@ components: dev_workspace_label: type: string nullable: true - description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'" + description: "Environment label of the dev workspace, e.g. 'dev' or 'staging'; null defaults to 'dev'" created_by: type: string nullable: true @@ -31191,8 +31219,8 @@ components: description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it" dev_workspace_label: type: string - enum: [dev, staging] - description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks" + enum: [dev, qa, test, uat, staging, demo, sandbox, preprod] + description: "Environment label for the dev workspace: its badge text and the branch it deploys to. Ignored for non-dev forks. Omitted defaults to 'dev'" required: - id - name @@ -31222,7 +31250,7 @@ components: dev_workspace_label: type: string nullable: true - description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'" + description: "Environment label of the dev workspace, e.g. 'dev' or 'staging'; null defaults to 'dev'" required: - id - name @@ -33408,6 +33436,7 @@ components: - DisableWorkspaceForking - RestrictDeployToDeployers - RestrictAnonymousAppDeployment + - RestrictPublicRunSharing RuleBypasserGroups: type: array description: Groups that can bypass this ruleset diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 84df4cce94..fd12036858 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -51,6 +51,7 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR}; use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES, }; +use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; use windmill_common::DYNAMIC_INPUT_CACHE; #[cfg(all(feature = "enterprise", feature = "instance_smtp"))] use windmill_common::{email_oss::send_email_html, server::load_smtp_config}; @@ -367,6 +368,10 @@ pub fn workspaced_service() -> Router { "/job_view_token/{id}", get(get_job_view_token).layer(cors.clone()), ) + .route( + "/job_public_view_token/{id}", + get(get_job_public_view_token).layer(cors.clone()), + ) .route("/run/dependencies", post(run_dependencies_job)) .route("/run/dependencies_async", post(run_dependencies_job_async)) .route("/run/flow_dependencies", post(run_flow_dependencies_job)) @@ -500,7 +505,63 @@ async fn get_job_view_token( // enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't // mint a transferable link for a job outside its allowed tags. require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; - let hmac = generate_view_token(&w_id, id, &db).await?; + let hmac = generate_view_token(&w_id, id, VIEW_TOKEN_DOMAIN, &db).await?; + Ok(format!("{id}.{hmac}")) +} + +/// Public flavor of [`get_job_view_token`]: the resulting link additionally grants read of +/// the job (and its flow subtree) to logged-out visitors, who land on the minimal public +/// run page. Read access is necessary but not sufficient — exposing workspace data to the +/// anonymous internet is a privileged action, gated like an app's anonymous execution mode. +async fn get_job_public_view_token( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + + // Anonymous readers of a public link carry no scope to confine, so the link would + // reach out-of-scope descendants of an in-scope job (mixed-tag flow trees). A tag + // scope is a hard restriction: refuse rather than silently narrow the link. + if get_scope_tags(&authed).is_some() { + return Err(Error::PermissionDenied( + "A tag-scoped token cannot share a run publicly: the resulting link is read \ + anonymously and could not carry the tag restriction to the run's steps." + .to_string(), + )); + } + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::RestrictPublicRunSharing, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + + let hmac = generate_view_token(&w_id, id, PUBLIC_VIEW_TOKEN_DOMAIN, &db).await?; + + // The link is stateless and permanent, so the mint is the only moment this is + // observable: audit it unconditionally rather than through the opt-in job-view log. + let mut tx = db.begin().await?; + audit_log( + &mut *tx, + &AuditAuthor::from(&authed), + "jobs.share_publicly", + ActionKind::Create, + &w_id, + Some(&id.to_string()), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("{id}.{hmac}")) } @@ -1318,7 +1379,7 @@ struct GetJobQuery { /// job UUID, even though the same job is hidden from them in `jobs/list` /// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read) /// -/// Unauthenticated callers are still handled by each handler's anonymous-job check; +/// Unauthenticated callers go through [`require_unauthed_job_read_access`] instead; /// this gate applies only when a user is authenticated. Access is granted when: /// - the caller created the job (`created_by`) — covers app components, webhooks and /// the caller's own runs, whose `permissioned_as` is the policy identity rather @@ -1445,9 +1506,13 @@ async fn require_job_read_access( } // Share read link: a valid view token minted by someone with read access grants - // this authenticated member read of the shared job and its flow subtree. + // this authenticated member read of the shared job and its flow subtree. Either + // audience does — the public one is a superset of the member one. if let Some(token) = view_token { - if validate_view_token(db, w_id, job_id, token).await? { + if validate_view_token(db, w_id, job_id, token) + .await? + .is_some() + { return Ok(()); } } @@ -1540,43 +1605,109 @@ async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Re }) } +/// Who a share read link was minted for. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ViewTokenAudience { + /// Read of the job subtree for an authenticated workspace member. + Member, + /// The same read, additionally granted to logged-out visitors. + Public, +} + /// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is /// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants /// read of that job and its whole flow subtree, so the run page can present a single -/// link that also renders the flow's steps. Returns true iff the signature is valid -/// AND `accessed_job_id` is the shared job or one of its descendants. +/// link that also renders the flow's steps. Returns the token's audience iff the +/// signature is valid AND `accessed_job_id` is the shared job or one of its descendants. async fn validate_view_token( db: &DB, w_id: &str, accessed_job_id: &Uuid, token: &str, -) -> error::Result { +) -> error::Result> { let Some((shared_id_str, provided_hmac)) = token.split_once('.') else { - return Ok(false); + return Ok(None); }; let Ok(shared_id) = Uuid::parse_str(shared_id_str) else { - return Ok(false); + return Ok(None); }; let Ok(provided_bytes) = hex::decode(provided_hmac) else { - return Ok(false); + return Ok(None); }; - // Constant-time verification (same domain as `generate_view_token`, mirroring + // Constant-time verification (same domains as `generate_view_token`, mirroring // `verify_suspended_secret`); avoids the timing side-channel of comparing the // hex strings with `!=`. let key = get_workspace_key(w_id, db).await?; - let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; - mac.update(shared_id.as_bytes()); - mac.update(b"view_token"); - if mac.verify_slice(&provided_bytes).is_err() { - return Ok(false); - } + let verify = |domain: &[u8]| -> error::Result { + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; + mac.update(shared_id.as_bytes()); + mac.update(domain); + Ok(mac.verify_slice(&provided_bytes).is_ok()) + }; + let audience = if verify(VIEW_TOKEN_DOMAIN)? { + ViewTokenAudience::Member + } else if verify(PUBLIC_VIEW_TOKEN_DOMAIN)? { + ViewTokenAudience::Public + } else { + return Ok(None); + }; if accessed_job_id == &shared_id { - return Ok(true); + return Ok(Some(audience)); } // The token authorizes the shared job's subtree: accessed must descend from it, // i.e. the shared job is among accessed's ancestors. let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?; - Ok(chain.contains(&shared_id)) + Ok(chain.contains(&shared_id).then_some(audience)) +} + +/// Whether `token` is a *public* share link covering `job_id`. This is the only thing +/// that lets a logged-out caller read a job that was not itself run anonymously. +async fn public_view_token_grants( + db: &DB, + w_id: &str, + job_id: &Uuid, + token: Option<&str>, +) -> error::Result { + let Some(token) = token else { + return Ok(false); + }; + Ok(validate_view_token(db, w_id, job_id, token).await? == Some(ViewTokenAudience::Public)) +} + +/// Read gate for logged-out callers, the counterpart of [`require_job_read_access`]: a job +/// run anonymously is public by construction, and a public share link publishes any other. +async fn require_unauthed_job_read_access( + db: &DB, + w_id: &str, + job_id: &Uuid, + created_by: &str, + view_token: Option<&str>, +) -> error::Result<()> { + if created_by == "anonymous" || public_view_token_grants(db, w_id, job_id, view_token).await? { + return Ok(()); + } + Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )) +} + +/// The read gate of every handler that serves both audiences (the `jobs_u` router): +/// [`require_job_read_access`] when logged in, [`require_unauthed_job_read_access`] when not. +async fn require_opt_authed_job_read_access( + db: &DB, + user_db: &UserDB, + opt_authed: &Option, + w_id: &str, + job_id: &Uuid, + created_by: &str, + view_token: Option<&str>, +) -> error::Result<()> { + match opt_authed { + Some(authed) => { + require_job_read_access(db, user_db, authed, w_id, job_id, created_by, view_token).await + } + None => require_unauthed_job_read_access(db, w_id, job_id, created_by, view_token).await, + } } lazy_static::lazy_static! { @@ -1745,7 +1876,12 @@ async fn get_job( false }; - let mut get = GetQuery::new().with_in_tags(tags.as_ref()); + let public_view_grant = opt_authed.is_none() + && public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?; + + let mut get = GetQuery::new() + .with_in_tags(tags.as_ref()) + .with_public_view_grant(public_view_grant); if !has_valid_approval_token { get = get.with_auth(&opt_authed); } @@ -1760,21 +1896,21 @@ async fn get_job( let mut job = get.fetch(&db, &id, &w_id).await?; job.fetch_outstanding_wait_time(&db).await?; - // A valid approval token is itself the capability; otherwise an authenticated - // caller must pass the same visibility as `jobs/list` (see `require_job_read_access`). - if !has_valid_approval_token { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - job.created_by(), - view_token.as_deref(), - ) - .await?; - } + // A valid approval token is itself the capability; otherwise the caller must pass the + // same visibility as `jobs/list` (see `require_job_read_access`), or hold a public + // share link when logged out — which `public_view_grant` already established above, + // so skip re-deriving it here: this handler is what the public run page polls. + if !has_valid_approval_token && !public_view_grant { + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + job.created_by(), + view_token.as_deref(), + ) + .await?; } log_job_view( @@ -1871,6 +2007,10 @@ struct GetQuery<'a> { with_flow: bool, with_auth: Option<&'a Option>, with_in_tags: Option<&'a Vec<&'a str>>, + /// A public share link covering this job was presented: lifts the logged-out + /// restriction in [`Self::check_auth`], the way a view token lifts the ACL check + /// in `require_job_read_access` for a member. + with_public_view_grant: bool, } impl<'a> GetQuery<'a> { @@ -1881,6 +2021,7 @@ impl<'a> GetQuery<'a> { with_flow: true, with_auth: None, with_in_tags: None, + with_public_view_grant: false, } } @@ -1905,9 +2046,16 @@ impl<'a> GetQuery<'a> { Self { with_in_tags: in_tags, ..self } } + fn with_public_view_grant(self, granted: bool) -> Self { + Self { with_public_view_grant: granted, ..self } + } + fn check_auth(&self, email: Option<&str>) -> error::Result<()> { if let Some(email) = email { - if self.with_auth.is_some_and(|x| x.is_none()) && email != "anonymous" { + if self.with_auth.is_some_and(|x| x.is_none()) + && email != "anonymous" + && !self.with_public_view_grant + { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users" .to_string(), @@ -2389,22 +2537,16 @@ async fn get_completed_job_logs_tail( .await?; if let Some(record) = record { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &record.created_by, - view_token.as_deref(), - ) - .await?; - } else if record.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; let logs = record.logs.unwrap_or_default(); Ok(Json(logs)) @@ -2453,22 +2595,16 @@ async fn get_job_logs( .await?; if let Some(record) = record { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &record.created_by, - view_token.as_deref(), - ) - .await?; - } else if record.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; let logs = record.logs.unwrap_or_default(); log_job_view( @@ -2522,10 +2658,15 @@ async fn get_job_logs( .await?; let text = not_found_if_none(text, "Job Logs", id.to_string())?; - if opt_authed.is_none() && text.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); + if opt_authed.is_none() { + require_unauthed_job_read_access( + &db, + &w_id, + &id, + &text.created_by, + view_token.as_deref(), + ) + .await?; } let logs = text.logs.unwrap_or_default(); @@ -2629,22 +2770,16 @@ async fn authorize_flow_tree_read( let root_job = not_found_if_none(root_job, "Job", id.to_string())?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - db, - user_db, - authed, - w_id, - &id, - &root_job.created_by, - view_token.as_deref(), - ) - .await?; - } else if root_job.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + db, + user_db, + &opt_authed, + w_id, + &id, + &root_job.created_by, + view_token.as_deref(), + ) + .await?; log_job_view( db, @@ -3625,22 +3760,16 @@ async fn get_args( .await?; if let Some(record) = record { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &record.created_by, - view_token.as_deref(), - ) - .await?; - } else if record.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; log_job_view( &db, @@ -3664,22 +3793,16 @@ async fn get_args( .fetch_optional(&db) .await?; let record = not_found_if_none(record, "Job Args", id.to_string())?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &record.created_by, - view_token.as_deref(), - ) - .await?; - } else if record.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; log_job_view( &db, @@ -4233,7 +4356,9 @@ pub async fn resume_suspended_flow_as_owner( // --- New approval system endpoints --- -use windmill_common::variables::{generate_approval_token, generate_view_token}; +use windmill_common::variables::{ + generate_approval_token, generate_view_token, PUBLIC_VIEW_TOKEN_DOMAIN, VIEW_TOKEN_DOMAIN, +}; /// Verify an approval token against the workspace key + job_id. async fn validate_approval_token( @@ -4698,7 +4823,7 @@ async fn get_approval_info( // Possession of view rights over this approval is sufficient to mint a // share-read-link token for the flow: it only grants read (no resume), and only to // an authenticated workspace member, so it never widens what the approver can do. - let hmac = generate_view_token(&w_id, row.id, &db).await?; + let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?; let view_token = Some(format!("{}.{hmac}", row.id)); Ok(Json(ApprovalInfo { @@ -5211,7 +5336,7 @@ pub async fn get_suspended_job_flow( // Possession of a valid approval secret is sufficient to mint a share-read-link // token for the parent flow: it only grants read (no resume), and only to an // authenticated workspace member, so it never widens what the approver can do. - let hmac = generate_view_token(&w_id, flow_id, &db).await?; + let hmac = generate_view_token(&w_id, flow_id, VIEW_TOKEN_DOMAIN, &db).await?; let view_token = Some(format!("{flow_id}.{hmac}")); Ok(Json(SuspendedJobFlow { job: flow, approvers, view_token }).into_response()) @@ -7630,6 +7755,7 @@ pub async fn stream_job( poll_delay_ms, early_return, has_failure_module, + false, ); let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok)); @@ -9329,8 +9455,7 @@ async fn get_log_file( } // Authorization: the log file directory is the job id, so gate access the same - // way as get_job_logs — the caller must be able to read the job. Non-logged-in - // callers may only read logs of jobs created by the anonymous user. + // way as get_job_logs — the caller must be able to read the job. let tags = opt_authed .as_ref() .map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec())) @@ -9344,22 +9469,16 @@ async fn get_log_file( .fetch_optional(&db) .await? .ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &job_id, - &created_by, - view_token.as_deref(), - ) - .await?; - } else if created_by != "anonymous" { - return Err(error::Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &job_id, + &created_by, + view_token.as_deref(), + ) + .await?; let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR); // SECURITY (defense in depth): refuse to read through a symlink so a planted @@ -9453,6 +9572,10 @@ async fn get_job_update( ) .await?; } + // A public share link authorizes a logged-out read of this job, seeding the latch + // that would otherwise confine it to anonymously-run jobs. + let mut unauthed_read_authorized = opt_authed.is_none() + && public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?; Ok(Json( get_job_update_data( &opt_authed, @@ -9473,7 +9596,7 @@ async fn get_job_update( None, false, &mut false, - &mut false, + &mut unauthed_read_authorized, ) .await?, )) @@ -9499,7 +9622,7 @@ async fn get_job_update_sse( }): Query, ) -> error::Result { // Authorize once at connection time; `created_by` cannot change for a given job, - // mirroring the per-stream `anonymous_verified` latch in the streaming loop. + // mirroring the per-stream `unauthed_read_authorized` latch in the streaming loop. if let Some(authed) = opt_authed.as_ref() { require_job_update_read_access( &db, @@ -9511,6 +9634,8 @@ async fn get_job_update_sse( ) .await?; } + let unauthed_read_authorized = opt_authed.is_none() + && public_view_token_grants(&db, &w_id, &job_id, view_token.as_deref()).await?; let (tx, rx) = tokio::sync::mpsc::channel(32); @@ -9532,6 +9657,7 @@ async fn get_job_update_sse( poll_delay_ms, None, false, + unauthed_read_authorized, ); let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| { @@ -9571,6 +9697,9 @@ pub fn start_job_update_sse_stream( poll_delay_ms: Option, early_return: Option, has_failure_module: bool, + // Seeds the per-stream latch below: set when the caller is logged out but presented + // a public share link for this job, which authorizes the whole stream up front. + unauthed_read_authorized: bool, ) -> () { tokio::spawn(async move { let mut log_offset = initial_log_offset; @@ -9580,10 +9709,10 @@ pub fn start_job_update_sse_stream( // Latched once the early_return node's failure is observed alongside a // failure_module — subsequent polls then skip the redundant per-node lookup. let mut early_return_suppressed = false; - // Latched once we've verified the job was created by "anonymous" — for + // Latched once a logged-out caller's read of this job has been authorized — for // unauthenticated SSE streams, this gates access and is checked once per // stream rather than once per poll (created_by cannot change). - let mut anonymous_verified = false; + let mut unauthed_read_authorized = unauthed_read_authorized; // Send initial update immediately let mut running = running; @@ -9609,7 +9738,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, - &mut anonymous_verified, + &mut unauthed_read_authorized, ) .await { @@ -9732,7 +9861,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, - &mut anonymous_verified, + &mut unauthed_read_authorized, ) .await { @@ -9886,7 +10015,9 @@ async fn get_job_update_data( early_return: Option<&str>, has_failure_module: bool, early_return_suppressed: &mut bool, - anonymous_verified: &mut bool, + // Latched gate for logged-out callers: once true, this job's updates are readable + // without a session (job run anonymously, or a public share link presented). + unauthed_read_authorized: &mut bool, ) -> error::Result { let tags = if log_view { log_job_view( @@ -9908,14 +10039,13 @@ async fn get_job_update_data( let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some(); if only_result.unwrap_or(false) { - // Unauthenticated callers may only read jobs whose creator is "anonymous". + // Unauthenticated callers are confined to jobs they may read logged out. // The non-only_result branch enforces this via `record.created_by` from its // main query, but the only_result branch below fetches solely the result by - // (workspace_id, job_id), so we guard here to close the gap. The - // `anonymous_verified` flag is preserved across SSE poll iterations so the - // lookup only happens once per stream — `created_by` cannot change for a - // given job once it has been created. - if opt_authed.is_none() && !*anonymous_verified { + // (workspace_id, job_id), so we guard here to close the gap. The latch is + // preserved across SSE poll iterations so the lookup only happens once per + // stream — `created_by` cannot change for a given job once it has been created. + if opt_authed.is_none() && !*unauthed_read_authorized { let created_by = sqlx::query_scalar!( "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", job_id, @@ -9931,7 +10061,7 @@ async fn get_job_update_data( .to_string(), )); } - *anonymous_verified = true; + *unauthed_read_authorized = true; } let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) = @@ -10172,15 +10302,22 @@ async fn get_job_update_data( .await? .ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?; - if opt_authed.is_none() && record.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); + if opt_authed.is_none() && !*unauthed_read_authorized { + if record.created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users" + .to_string(), + )); + } + *unauthed_read_authorized = true; } let job = if record.completed.unwrap_or(false) && get_full_job_on_completion { let get = GetQuery::new() .with_auth(&opt_authed) + // Already authorized above, latch included — don't re-derive it from + // `created_by` here or a public share link would lose the full job. + .with_public_view_grant(*unauthed_read_authorized) .without_logs() .without_code(); Some(get.fetch(&db, job_id, &w_id).await?) @@ -10306,19 +10443,24 @@ async fn get_completed_job<'a>( .map(|authed| get_scope_tags(authed)) .flatten(); + let public_view_grant = opt_authed.is_none() + && public_view_token_grants(&db, &w_id, &id, view_token.as_deref()).await?; + let job_o = GetQuery::new() .with_auth(&opt_authed) .with_in_tags(tags.as_ref()) + .with_public_view_grant(public_view_grant) .fetch_completed(&db, &id, &w_id) .await?; let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( + // `public_view_grant` already settled the logged-out case above — don't re-derive it. + if !public_view_grant { + require_opt_authed_job_read_access( &db, &user_db, - authed, + &opt_authed, &w_id, &id, &cj.created_by, @@ -10454,22 +10596,16 @@ async fn get_completed_job_result( }; if !approval_secret_ok { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &created_by, - view_token.as_deref(), - ) - .await?; - } else if created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; } format_result( @@ -10571,22 +10707,16 @@ async fn get_completed_job_result_maybe( if let Some(mut res) = result_o { format_result(res.result_columns.as_ref(), res.result.as_mut()); - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &res.created_by, - view_token.as_deref(), - ) - .await?; - } else if res.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &res.created_by, + view_token.as_deref(), + ) + .await?; log_job_view( &db, @@ -10617,23 +10747,16 @@ async fn get_completed_job_result_maybe( .fetch_optional(&db) .await?; if let Some(created_by) = created_by { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &created_by, - view_token.as_deref(), - ) - .await?; - } else if created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users" - .to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; } let started = sqlx::query_scalar!( "SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2", @@ -10707,9 +10830,8 @@ async fn get_dispatch_events( // Gate on the producer job's visibility, exactly like // get_completed_job_timing on the same unauthed router: scope tags - // first, then per-job read access for authed users, anonymous-only - // jobs otherwise. The dispatch_event FK to v2_job(id) guarantees the - // producer row exists for any extant event. + // first, then the shared per-audience read gate. The dispatch_event FK + // to v2_job(id) guarantees the producer row exists for any extant event. let producer = sqlx::query!( r#"SELECT created_by AS "created_by!" FROM v2_job @@ -10722,22 +10844,16 @@ async fn get_dispatch_events( .await?; let producer = not_found_if_none(producer, "Job", id.to_string())?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &producer.created_by, - view_token.as_deref(), - ) - .await?; - } else if producer.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &producer.created_by, + view_token.as_deref(), + ) + .await?; let rows = sqlx::query!( r#"SELECT @@ -10890,22 +11006,16 @@ async fn get_completed_job_timing( let result = not_found_if_none(result, "Completed Job", id.to_string())?; - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &result.created_by, - view_token.as_deref(), - ) - .await?; - } else if result.created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &result.created_by, + view_token.as_deref(), + ) + .await?; Ok(Json(JobTiming { created_at: result.created_at, @@ -11200,23 +11310,16 @@ async fn get_otel_traces( match job { Some(created_by) => { - if let Some(authed) = opt_authed.as_ref() { - require_job_read_access( - &db, - &user_db, - authed, - &w_id, - &id, - &created_by, - view_token.as_deref(), - ) - .await?; - } else if created_by != "anonymous" { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users" - .to_string(), - )); - } + require_opt_authed_job_read_access( + &db, + &user_db, + &opt_authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; } None => { return Err(Error::NotFound(format!("Job {} not found", id))); diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index d86912d8d0..235e178609 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -187,14 +187,18 @@ pub async fn get_triggers_count_internal( .await? .unwrap_or(0); + // These counts are independent reads: they share a connection to avoid one acquire + // per trigger kind, but must not share a transaction. A single failing count would + // abort it and, since `trigger_count` falls back to 0 on error, silently zero every + // count after it. #[allow(unused)] - let mut tx = db.begin().await?; + let mut conn = db.acquire().await?; #[cfg(feature = "http_trigger")] let http_routes_count = { use crate::triggers::http::HttpTrigger; let count = HttpTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -205,7 +209,7 @@ pub async fn get_triggers_count_internal( let websocket_count = { use crate::triggers::websocket::WebsocketTrigger; let count = WebsocketTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -216,7 +220,7 @@ pub async fn get_triggers_count_internal( let kafka_count = { use crate::triggers::kafka::KafkaTrigger; let count = KafkaTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -227,7 +231,7 @@ pub async fn get_triggers_count_internal( let nats_count = { use crate::triggers::nats::NatsTrigger; let count = NatsTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -238,7 +242,7 @@ pub async fn get_triggers_count_internal( let postgres_count = { use crate::triggers::postgres::PostgresTrigger; let count = PostgresTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -249,7 +253,7 @@ pub async fn get_triggers_count_internal( let mqtt_count = { use crate::triggers::mqtt::MqttTrigger; let count = MqttTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -260,7 +264,7 @@ pub async fn get_triggers_count_internal( let amqp_count = { use crate::triggers::amqp::AmqpTrigger; let count = AmqpTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -270,7 +274,9 @@ pub async fn get_triggers_count_internal( #[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))] let sqs_count = { use crate::triggers::sqs::SqsTrigger; - let count = SqsTrigger.trigger_count(&mut tx, w_id, is_flow, path).await; + let count = SqsTrigger + .trigger_count(&mut conn, w_id, is_flow, path) + .await; count }; #[cfg(not(all(feature = "sqs_trigger", feature = "enterprise", feature = "private")))] @@ -279,7 +285,9 @@ pub async fn get_triggers_count_internal( #[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))] let gcp_count = { use crate::triggers::gcp::GcpTrigger; - let count = GcpTrigger.trigger_count(&mut tx, w_id, is_flow, path).await; + let count = GcpTrigger + .trigger_count(&mut conn, w_id, is_flow, path) + .await; count }; #[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))] @@ -289,7 +297,7 @@ pub async fn get_triggers_count_internal( let azure_count = { use crate::triggers::azure::AzureTrigger; let count = AzureTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; @@ -300,14 +308,14 @@ pub async fn get_triggers_count_internal( let email_count = { use crate::triggers::email::EmailTrigger; let count = EmailTrigger - .trigger_count(&mut tx, w_id, is_flow, path) + .trigger_count(&mut conn, w_id, is_flow, path) .await; count }; #[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))] let email_count = 0; - tx.commit().await?; + drop(conn); let webhook_count = (if is_flow { sqlx::query_scalar!( diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 0508586182..44cff6566a 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -579,6 +579,7 @@ async fn route_job( None, early_return, has_failure_module, + false, ); let body = axum::body::Body::from_stream( diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 88b823eda9..8310051d75 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -192,14 +192,25 @@ pub async fn generate_approval_token( Ok(hex::encode(mac.finalize().into_bytes())) } -/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`. +/// Domain separator of the member-audience view token (see [`generate_view_token`]). +pub const VIEW_TOKEN_DOMAIN: &[u8] = b"view_token"; + +/// Domain separator of the public-audience view token (see [`generate_view_token`]). +/// Distinct from [`VIEW_TOKEN_DOMAIN`] so that already-shared member links keep granting +/// only what they were minted for: going public is always an explicit new mint. +pub const PUBLIC_VIEW_TOKEN_DOMAIN: &[u8] = b"public_view_token"; + +/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || domain)`. /// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can /// never be used as a view token (or vice-versa). Used to build a "share read link" that -/// grants an authenticated workspace member read access to a job (and its flow subtree) -/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token. +/// grants read access to a job (and its flow subtree) to someone who otherwise lacks ACL +/// on it: an authenticated workspace member under [`VIEW_TOKEN_DOMAIN`], anyone holding +/// the link (logged in or not) under [`PUBLIC_VIEW_TOKEN_DOMAIN`]. No expiry/revocation +/// (stateless), like the approval token. pub async fn generate_view_token( w_id: &str, job_id: uuid::Uuid, + domain: &[u8], db: &DB, ) -> crate::error::Result { use hmac::{Hmac, Mac}; @@ -208,7 +219,7 @@ pub async fn generate_view_token( let mut mac = Hmac::::new_from_slice(key.as_bytes()) .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; mac.update(job_id.as_bytes()); - mac.update(b"view_token"); + mac.update(domain); Ok(hex::encode(mac.finalize().into_bytes())) } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index ddf2af3a26..d570e7ec32 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -70,6 +70,7 @@ bitflags::bitflags! { const DISABLE_WORKSPACE_FORKING = 1 << 1; const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2; const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3; + const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4; } } @@ -81,6 +82,7 @@ pub enum ProtectionRuleKind { DisableWorkspaceForking, RestrictDeployToDeployers, RestrictAnonymousAppDeployment, + RestrictPublicRunSharing, } impl ProtectionRuleKind { @@ -98,6 +100,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictAnonymousAppDeployment => { ProtectionRules::RESTRICT_ANONYMOUS_APP_DEPLOYMENT } + ProtectionRuleKind::RestrictPublicRunSharing => { + ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING + } } } @@ -113,6 +118,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictAnonymousAppDeployment => { "Making an app publicly accessible without login (anonymous execution mode) is restricted in this workspace" } + ProtectionRuleKind::RestrictPublicRunSharing => { + "Sharing a run publicly (readable without login) is restricted in this workspace" + } } } } @@ -175,7 +183,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28871/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28870/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28890/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. @@ -310,7 +318,7 @@ fn is_false(b: &bool) -> bool { !*b } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GitRepositorySettings { #[serde(skip_serializing_if = "Option::is_none")] pub exclude_types_override: Option>, @@ -508,7 +516,7 @@ impl AutoPullSettings { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GitSyncSettings { pub include_path: Vec, pub include_type: Vec, @@ -1525,10 +1533,12 @@ pub async fn resolve_fork_branch_target( .fetch_optional(db) .await? } else if branch != expected_base { - // Environment-label branch (`dev`/`staging`) of a dev-workspace child. - // Dev workspaces only exist directly under a root, so no recursion here. - // The tracked-branch guard keeps a label that collides with the tracked - // branch from double-routing (the parent's own pull already covers it). + // Environment-label branch (`dev`, `staging`, ...) of a dev-workspace child. + // Direct children only: a dev nested under another dev is parent-managed + // by that dev, which holds no auto-pull config of its own, so no branch + // pushed to this repo routes to it. The tracked-branch guard keeps a + // label that collides with the tracked branch from double-routing (the + // parent's own pull already covers it). sqlx::query_scalar!( "SELECT id FROM workspace \ WHERE parent_workspace_id = $1 AND NOT deleted AND is_dev_workspace \ diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 13c22fb9fa..88b7416977 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -361,7 +361,17 @@ pub trait TriggerCrud: Send + Sync + 'static { .bind(script_path) .fetch_one(&mut *tx) .await - .unwrap_or(0); + // Falling back to 0 keeps one unreadable table from failing the whole count + // endpoint, but the cause must still reach the logs: a silent 0 is + // indistinguishable from "no triggers" in the UI. + .unwrap_or_else(|err| { + tracing::error!( + "failed to count {} triggers of {} {script_path} in {workspace_id}: {err:#}", + Self::TABLE_NAME, + if is_flow { "flow" } else { "script" } + ); + 0 + }); count } diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 01e1203b6b..d64499878b 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -571,12 +571,8 @@ async fn gen_bunfig( NPMRC.read().await.clone() }; - if let Some(ref npmrc_content) = npmrc { - if !npmrc_content.trim().is_empty() { - tracing::debug!("Writing .npmrc for bun from npmrc setting"); - write_file(job_dir, ".npmrc", npmrc_content)?; - return Ok(()); - } + if npmrc.as_ref().is_some_and(|c| !c.trim().is_empty()) { + return write_bun_registry_config(job_dir, npmrc, None, None); } let (registry, bunfig_install_scopes) = if let Some(conn) = db { @@ -606,6 +602,35 @@ async fn gen_bunfig( BUNFIG_INSTALL_SCOPES.read().await.clone(), ) }; + write_bun_registry_config(job_dir, None, registry, bunfig_install_scopes) +} + +/// The files [`write_bun_registry_config`] may create in the directory bun installs from. +/// Both can hold a registry auth token, so `prepare-deps` deletes them by these names once +/// the install is over. +pub(crate) const BUN_NPMRC_FILE: &str = ".npmrc"; +pub(crate) const BUN_CONFIG_FILE: &str = "bunfig.toml"; + +/// Write the registry configuration `bun install` picks up from its working directory: the +/// `npmrc` setting verbatim as `.npmrc` when set, otherwise a `bunfig.toml` holding the +/// registry URL, its auth token and the install scopes. +/// +/// Shared with the debugger's `prepare-deps`, which resolves the same settings without a +/// database (see `prepare_deps.rs`), so the two install paths configure bun identically. +pub(crate) fn write_bun_registry_config( + job_dir: &str, + npmrc: Option, + registry: Option, + bunfig_install_scopes: Option, +) -> Result<()> { + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + tracing::debug!("Writing .npmrc for bun from npmrc setting"); + write_file(job_dir, BUN_NPMRC_FILE, npmrc_content)?; + return Ok(()); + } + } + if registry.is_some() || bunfig_install_scopes.is_some() { let (url, token_opt) = if let Some(ref s) = registry { let url = s.trim(); @@ -635,7 +660,7 @@ registry = {} .unwrap_or("".to_string()) ); tracing::debug!("Writing following bunfig.toml: {bunfig_toml}"); - let _ = write_file(&job_dir, "bunfig.toml", &bunfig_toml)?; + let _ = write_file(&job_dir, BUN_CONFIG_FILE, &bunfig_toml)?; } Ok(()) } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index ce9784331c..e91a1957c6 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1597,6 +1597,10 @@ pub async fn do_duckdb( ) .await?; + #[cfg(feature = "private")] + let query_block_list = + crate::duckdb_isolation_ee::apply_duckdb_isolation(query_block_list)?; + let base_internal_url = client.base_internal_url.clone(); let w_id = job.workspace_id.clone(); let job_dir = job_dir.to_string(); diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 8cb0551254..e3e7868548 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -43,6 +43,8 @@ mod deno_executor; mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; +#[cfg(all(feature = "duckdb", feature = "private"))] +mod duckdb_isolation_ee; mod global_cache; mod go_executor; mod graphql_executor; diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 2e16e565c2..9c8a5179e3 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -12,6 +12,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; +use crate::bun_executor::{write_bun_registry_config, BUN_CONFIG_FILE, BUN_NPMRC_FILE}; use crate::worker::non_empty_env; use crate::{ BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST, @@ -92,9 +93,10 @@ lazy_static::lazy_static! { /// UV binary path static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string()); - /// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance - /// settings the job path resolves are unreachable here: their env-var equivalents are the - /// only registry configuration the debugger can see. + /// Fallbacks for the registry settings, used when the caller sends none: this process has + /// no database, so the instance settings only reach it through the request (see + /// [`RegistryConfig`]), and a debug service that cannot fetch them still configures the + /// installer from its own environment. static ref PY_INDEX_URL: Option = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL")); static ref PY_EXTRA_INDEX_URL: Option = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL")); /// uv defaults to `first-index`; the job path overrides it so a package missing from the @@ -112,10 +114,153 @@ const p = { }; "#; +/// The registry settings resolved from the instance settings by the caller. +/// +/// This process runs without a database, so `GET /api/debug/registry_config` is where the +/// debug service reads them and this request field is how they get here. They are consumed +/// to configure the installer and never handed to the debug session itself: an index URL +/// embeds credentials and the session executes user-supplied code. +/// +/// Field names are the `global_settings` keys, so the service forwards the endpoint's +/// response verbatim. +#[derive(Deserialize, Default)] +pub struct RegistryConfig { + pub npm_config_registry: Option, + pub npmrc: Option, + pub bunfig_install_scopes: Option, + pub pip_index_url: Option, + pub pip_extra_index_url: Option, + pub uv_index_strategy: Option, +} + #[derive(Deserialize)] pub struct PrepareRequest { pub code: String, pub language: String, + /// Interpreter the caller will run the script with. The venv must be built against it: + /// site-packages is put on that interpreter's sys.path, and a wheel with a compiled + /// extension built for another version is simply invisible there. + #[serde(default)] + pub python_path: Option, + #[serde(default)] + pub registry: RegistryConfig, +} + +/// A blank value means unset, as it does for the same setting on a worker. +fn configured(value: &Option) -> Option { + value.clone().filter(|v| !v.trim().is_empty()) +} + +/// Where the registry configuration for one `bun install` is written. +/// +/// Deliberately not the install directory: the debug session resolves its `node_modules` +/// symlink into that directory, and the sandbox bind-mounts all of `/tmp` into every session, +/// so a concurrent session could read the credentials of an install in flight. `/var/tmp` is a +/// tmpfs private to each jail (`debugger/nsjail.debug.config.proto`), which also takes the +/// credentials with it when a jailed install is killed. `bun install` reads them from here +/// through `--config` and `HOME`. +const REGISTRY_CONFIG_ROOT: &str = "/var/tmp/windmill-debug-registry"; + +/// How long a configuration directory may survive before the next install treats it as debris. +/// The caller kills an install with SIGKILL, leaving nothing able to clean up after it, and an +/// unjailed install is the only one that writes somewhere outliving the process at all. Well +/// past any install: the caller's own timeout is two minutes by default. +const REGISTRY_CONFIG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + +/// Removes the registry configuration when the install ends, whichever way it ends. +struct RegistryConfigDir(Option); + +impl Drop for RegistryConfigDir { + fn drop(&mut self) { + if let Some(dir) = self.0.as_deref() { + remove_registry_config_dir(dir); + } + } +} + +/// Write the registry configuration for one install and return the directory holding it, empty +/// when there is nothing to write. +/// +/// Falls back to the install directory if the private root is not writable: an install that +/// reaches its registry matters more than the isolation above, which only holds for sessions +/// that run under nsjail in the first place. +fn write_registry_config_dir( + job_id: &uuid::Uuid, + job_dir: &str, + registry: &RegistryConfig, +) -> anyhow::Result { + let npmrc = configured(®istry.npmrc); + let npm_config_registry = configured(®istry.npm_config_registry); + let bunfig_install_scopes = configured(®istry.bunfig_install_scopes); + if npmrc.is_none() && npm_config_registry.is_none() && bunfig_install_scopes.is_none() { + return Ok(RegistryConfigDir(None)); + } + + let dir = format!("{}/{}", REGISTRY_CONFIG_ROOT, job_id); + let dir = match create_private_dir(&dir) { + Ok(()) => dir, + Err(e) => { + tracing::warn!("Could not create {dir} ({e}), keeping the registry configuration in the install directory"); + job_dir.to_string() + } + }; + // Claimed before anything is written to it, so a failure below still takes it down. + let held = RegistryConfigDir(Some(dir.clone())); + write_bun_registry_config(&dir, npmrc, npm_config_registry, bunfig_install_scopes)?; + Ok(held) +} + +fn create_private_dir(dir: &str) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::create_dir_all(REGISTRY_CONFIG_ROOT)?; + sweep_stale_registry_config(); + std::fs::DirBuilder::new().mode(0o700).create(dir) + } + #[cfg(not(unix))] + { + sweep_stale_registry_config(); + std::fs::create_dir_all(dir) + } +} + +/// Drop what an install that was killed could not clean up itself. +fn sweep_stale_registry_config() { + let Ok(entries) = std::fs::read_dir(REGISTRY_CONFIG_ROOT) else { + return; + }; + for entry in entries.flatten() { + let stale = entry + .metadata() + .and_then(|m| m.modified()) + .is_ok_and(|m| m.elapsed().is_ok_and(|age| age > REGISTRY_CONFIG_MAX_AGE)); + if stale { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +/// Delete the registry configuration once the install that needed it is over. A failure to +/// remove credentials has to be visible. +fn remove_registry_config_dir(dir: &str) { + if dir.starts_with(REGISTRY_CONFIG_ROOT) { + if let Err(e) = std::fs::remove_dir_all(dir) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {dir}: {e}"); + } + } + return; + } + // The fallback path above put them in the install directory, which has to survive. + for file in [BUN_NPMRC_FILE, BUN_CONFIG_FILE] { + let path = format!("{}/{}", dir, file); + if let Err(e) = std::fs::remove_file(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to remove registry configuration {path}: {e}"); + } + } + } } #[derive(Serialize)] @@ -178,15 +323,30 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap { envs } +/// CA bundle for the package index, most specific spelling first. A host behind a TLS-intercepting +/// proxy configures it under whichever name its other tooling uses, and the environment is cleared +/// below, so falling back past `PY_INDEX_CERT` is what makes those hosts work at all. +fn index_ca_bundle() -> Option { + INDEX_CERT + .clone() + .or_else(|| non_empty_env("SSL_CERT_FILE")) + .or_else(|| non_empty_env("REQUESTS_CA_BUNDLE")) + .or_else(|| non_empty_env("CURL_CA_BUNDLE")) +} + /// uv registry arguments, mirroring what the job path passes in `python_executor`. -fn uv_registry_args() -> Vec { +fn uv_registry_args(registry: &RegistryConfig) -> Vec { + let index_url = configured(®istry.pip_index_url).or_else(|| PY_INDEX_URL.clone()); + let extra_index_url = + configured(®istry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone()); + let mut args: Vec = vec![]; - if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() { + if let Some(urls) = extra_index_url.as_ref() { for url in urls.split(',') { args.extend(["--extra-index-url".to_string(), url.to_string()]); } } - if let Some(url) = PY_INDEX_URL.as_ref() { + if let Some(url) = index_url.as_ref() { args.extend(["--index-url".to_string(), url.to_string()]); } if let Some(hosts) = TRUSTED_HOST.as_ref() { @@ -201,7 +361,11 @@ fn uv_registry_args() -> Vec { } /// Prepare Python dependencies using uv -async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { +async fn prepare_python_deps_standalone( + code: &str, + python_path: Option<&str>, + registry: &RegistryConfig, +) -> PrepareResponse { // Parse imports from the code let packages = parse_python_imports(code); @@ -237,23 +401,36 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR))); common_uv_envs.insert( "UV_INDEX_STRATEGY".to_string(), - PY_INDEX_STRATEGY.to_string(), + configured(®istry.uv_index_strategy).unwrap_or_else(|| PY_INDEX_STRATEGY.to_string()), ); if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string()); } - if let Some(cert_path) = INDEX_CERT.as_ref() { + if let Some(cert_path) = index_ca_bundle() { // uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle - // reaches it through SSL_CERT_FILE, as in the job path. - common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string()); + // reaches it through SSL_CERT_FILE, as in the job path. It replaces uv's own roots rather + // than adding to them, so the file has to be a complete bundle. + common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path); + } + // The other spelling uv accepts. Like the bundle above it replaces uv's own roots rather than + // adding to them, so a directory holding only a private CA leaves public indexes untrusted. + if let Some(cert_dir) = non_empty_env("SSL_CERT_DIR") { + common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir); } - let registry_args = uv_registry_args(); + let registry_args = uv_registry_args(registry); // Step 1: Create virtual environment using uv // `--seed` resolves pip/setuptools from the index, so the venv also needs the registry // arguments: on a network that only reaches a private mirror it fails without them. let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()]; + // Without this uv picks its own interpreter, and the caller then puts a site-packages built + // for that version on a different interpreter's sys.path: pure-Python packages still import, + // anything with a compiled extension does not, and the error names the missing extension + // rather than the mismatch. + if let Some(python_path) = python_path { + venv_args.extend(["-p".to_string(), python_path.to_string()]); + } venv_args.extend(registry_args.iter().cloned()); let output = Command::new(UV_PATH.as_str()) @@ -375,16 +552,26 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse { /// Get common environment variables for Bun processes pub fn get_simple_bun_proc_envs() -> HashMap { - get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR))) + let mut envs = get_proc_envs(Some(("BUN_INSTALL_CACHE_DIR", &BUN_CACHE_DIR))); + // Bun reads none of the spellings uv does, so a custom CA reaches `bun install` only here. + if let Some(cert_path) = non_empty_env("NODE_EXTRA_CA_CERTS").or_else(index_ca_bundle) { + envs.insert("NODE_EXTRA_CA_CERTS".to_string(), cert_path); + } + envs } /// Prepare dependencies for a script without requiring database access. /// This is meant to be called from the CLI. -pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareResponse { +pub async fn prepare_deps_standalone( + code: &str, + language: &str, + python_path: Option<&str>, + registry: &RegistryConfig, +) -> PrepareResponse { // Route to the appropriate handler based on language match language { "python3" | "python" => { - return prepare_python_deps_standalone(code).await; + return prepare_python_deps_standalone(code, python_path, registry).await; } "bun" | "typescript" | "deno" => { // Continue with JS/TS handling below @@ -451,7 +638,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo }; } - let common_bun_proc_envs = get_simple_bun_proc_envs(); + let mut common_bun_proc_envs = get_simple_bun_proc_envs(); // Step 1: Run build.js to generate package.json let output = Command::new(&*BUN_PATH) @@ -544,17 +731,43 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo }; } - // Step 2: Run bun install + // Step 2: Run bun install, from the same registry configuration a job installs with. + let mut args = vec!["install".to_string()]; + let registry_config_dir = match write_registry_config_dir(&job_id, &job_dir, registry) { + Ok(dir) => dir, + Err(e) => { + return PrepareResponse { + node_modules_path: None, + venv_path: None, + job_dir: job_dir.clone(), + success: false, + error: Some(format!("Failed to write registry configuration: {}", e)), + install_stderr: None, + }; + } + }; + if let Some(dir) = registry_config_dir.0.as_ref() { + // Only one of the two files exists: `.npmrc` is read from the installer's home, + // `bunfig.toml` only from the path named here. + common_bun_proc_envs.insert("HOME".to_string(), dir.to_string()); + let bunfig = format!("{}/{}", dir, BUN_CONFIG_FILE); + if std::path::Path::new(&bunfig).exists() { + args.push(format!("--config={}", bunfig)); + } + } + let output = Command::new(&*BUN_PATH) .current_dir(&job_dir) .env_clear() .envs(common_bun_proc_envs) - .args(vec!["install"]) + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await; + drop(registry_config_dir); + match output { Ok(out) => { if !out.status.success() { @@ -649,7 +862,13 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { } }; - let response = prepare_deps_standalone(&request.code, &request.language).await; + let response = prepare_deps_standalone( + &request.code, + &request.language, + request.python_path.as_deref(), + &request.registry, + ) + .await; println!("{}", serde_json::to_string(&response)?); Ok(()) @@ -657,7 +876,27 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::PrepareResponse; + use super::{PrepareRequest, PrepareResponse}; + + /// The debug service and this CLI are deployed as separate images, and the service + /// forwards `GET /api/debug/registry_config` verbatim, `message` field included. So a + /// request from an older service carries no `registry` at all, and one from a newer + /// service carries fields this binary does not know. + #[test] + fn test_registry_is_optional_and_tolerates_unknown_fields() { + let without: PrepareRequest = + serde_json::from_str(r#"{"code": "import lodash", "language": "bun"}"#).unwrap(); + assert!(without.registry.npm_config_registry.is_none()); + + let with: PrepareRequest = serde_json::from_str( + r#"{"code": "", "language": "bun", "registry": {"npm_config_registry": "https://npm.example", "message": "for the user"}}"#, + ) + .unwrap(); + assert_eq!( + with.registry.npm_config_registry.as_deref(), + Some("https://npm.example") + ); + } /// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's /// stdout, so `install_stderr` has to stay additive: a response without an install failure diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 693d0295c8..2b99b9ff84 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -52,7 +52,7 @@ use crate::{ otel_oss::add_root_flow_job_to_otlp, worker_flow::update_flow_status_after_job_completion, JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload, - UpdateFlow, SAME_WORKER_REQUIREMENTS, + StepFailureKind, UpdateFlow, SAME_WORKER_REQUIREMENTS, }; use windmill_common::client::AuthedClient; @@ -465,6 +465,7 @@ pub fn start_background_processor( worker_dir, stop_early_override, token, + step_failure, }), time, }) => { @@ -485,7 +486,7 @@ pub fn start_background_processor( None, Arc::new(result), None, - true, + step_failure, &same_worker_tx, &worker_dir, stop_early_override, @@ -791,7 +792,7 @@ pub async fn handle_receive_completed_job( mem_peak, canceled_by, err, - false, + StepFailureKind::Normal, same_worker_tx.clone(), &worker_dir, worker_name, @@ -866,6 +867,20 @@ fn parse_git_sync_changes(result_raw: &str) -> Option<(Vec<(String, String)>, bo )) } +/// The pull script reports an unmergeable PR as a top-level `pr_check_error` +/// sentinel field. Matched as an exact field, never a substring: a successful +/// diff lists user-controlled repo paths that could embed the sentinel text. +#[cfg(all(feature = "enterprise", feature = "private"))] +fn parse_pr_check_error(result_raw: &str) -> Option { + serde_json::from_str::(result_raw) + .ok() + .and_then(|v| { + v.get("pr_check_error") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()) + }) +} + #[cfg(all(feature = "enterprise", feature = "private"))] fn format_change_list(changes: &[(String, String)]) -> Vec { let mut lines = Vec::new(); @@ -880,7 +895,19 @@ fn format_change_list(changes: &[(String, String)]) -> Vec { #[cfg(all(test, feature = "enterprise", feature = "private"))] mod git_sync_check_tests { - use super::{format_change_list, parse_git_sync_changes}; + use super::{format_change_list, parse_git_sync_changes, parse_pr_check_error}; + + #[test] + fn pr_check_error_is_a_field_not_a_substring() { + // A diff whose paths embed the sentinel text must not trip the verdict. + let diff = r#"{"changes":[{"type":"edited","path":"f/team/PR_MERGE_CONFLICTS.ts"}]}"#; + assert_eq!(parse_pr_check_error(diff), None); + let sentinel = r#"{"pr_check_error":"PR_MERGE_CONFLICTS","message":"m"}"#; + assert_eq!( + parse_pr_check_error(sentinel).as_deref(), + Some("PR_MERGE_CONFLICTS") + ); + } #[test] fn parse_empty_changes_is_in_sync() { @@ -972,7 +999,7 @@ async fn maybe_reconcile_git_sync_auto_pull( /// Branch a git-sync push job deployed to, mirroring the hub script's /// derivation: a dev workspace deploys to its environment-label branch -/// (`dev`/`staging`), other fork workspaces to `wm-fork//`, +/// (`dev`, `staging`, ...), other fork workspaces to `wm-fork//`, /// else the promotion `wm_deploy/**` formula (per-folder or per-item form). /// A dev workspace in promotion mode is the exception: it takes the promotion /// `wm_deploy/**` formula (per-item PRs into the parent) instead of its label @@ -1390,8 +1417,37 @@ async fn maybe_post_git_sync_check( } } } else { - // Phase 4: dry-run diff preview for a PR. - if !success { + // Phase 4: dry-run diff preview for a PR. An unmergeable PR has no + // diff; the pull script reports which sentinel applies (returned as a + // result — thrown bun errors reach the job result as truncated log tails). + let pr_check_error = parse_pr_check_error(result_raw); + if pr_check_error.as_deref() == Some("PR_MERGE_CONFLICTS") { + ( + "failure", + "Merge conflicts with the base branch".to_string(), + "This PR cannot be merged cleanly, so there is no deploy diff to compute. Resolve the conflicts and push again to re-run this check." + .to_string(), + ) + } else if pr_check_error.as_deref() == Some("PR_HEAD_REF_UNAVAILABLE") { + // Neutral, not failure: a transient fetch problem is Windmill-side + // and, unlike conflicts, has no fixing push that would re-run the + // check on its own — it must not hard-block the PR. + ( + "neutral", + "Could not compute the deploy diff".to_string(), + "Windmill could not fetch this PR's head or enough history from GitHub to compute its merge with the base. Push again to re-run this check." + .to_string(), + ) + } else if pr_check_error.is_some() { + // Unknown sentinel (script newer than this backend): an explicit + // error signal must not degrade into a "diff computed" verdict. + ( + "failure", + "Windmill diff failed".to_string(), + "The dry-run pull reported an unrecognized error. See the job in Windmill for details." + .to_string(), + ) + } else if !success { ( "failure", "Windmill diff failed".to_string(), @@ -1593,7 +1649,7 @@ pub async fn process_completed_job( canceled_by, result, started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1700,7 +1756,7 @@ pub async fn process_completed_job( duration_ms: d, }) }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1978,7 +2034,7 @@ pub async fn handle_job_error( mem_peak: i32, canceled_by: Option, err: Error, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: Option<&SameWorkerSender>, worker_dir: &str, worker_name: &str, @@ -2028,7 +2084,7 @@ pub async fn handle_job_error( canceled_by.clone(), Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()), None, - unrecoverable, + step_failure, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(), worker_dir, None, diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 10e436d326..4703c12556 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1747,7 +1747,7 @@ pub async fn handle_all_job_kind_error( 0, None, err, - false, + StepFailureKind::Normal, same_worker_tx, &worker_dir, &worker_name, @@ -3639,6 +3639,38 @@ pub struct UpdateFlow { pub worker_dir: String, pub stop_early_override: Option, pub token: String, + pub step_failure: StepFailureKind, +} + +/// Why the step a flow is being resumed from failed, which bounds what the engine may do next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepFailureKind { + /// The step failed by running, or did not fail at all. + Normal, + /// A suspend gate was disapproved or timed out. The worker that ran the approval step is + /// still alive, but the failure is recorded against the step the gate was holding back, + /// which never ran — so that step's `retry` and `continue_on_error` describe nothing that + /// happened, and honouring them would re-open the gate or skip the step outright. + /// `suspend.continue_on_disapprove_timeout` is how a flow opts into continuing past a gate. + SuspendNotApproved, + /// The step's worker died (OOM/zombie), or the flow status update itself errored. Neither + /// leaves state worth pinning to: in the first case that worker is gone, in the second the + /// flow's own bookkeeping is what just broke. + Unrecoverable, +} + +impl StepFailureKind { + /// Whether the failed module's own `retry` / `continue_on_error` still describe the + /// failure at hand. When they don't, the failure module is the only way forward. + pub fn honors_step_error_policy(self) -> bool { + matches!(self, Self::Normal) + } + + /// Whether follow-up work may still be pinned to the worker that ran the previous step, + /// via `same_worker` or dedicated flow-module runners. + pub fn keeps_worker_pin(self) -> bool { + !matches!(self, Self::Unrecoverable) + } } async fn do_nativets( @@ -3940,8 +3972,8 @@ pub async fn handle_queued_job( flow_runners, &killpill_rx, // A freshly pulled flow job is being executed by a live worker; the prior - // step (if any) completed normally, so this is never unrecoverable here. - false, + // step (if any) completed normally. + StepFailureKind::Normal, )) .warn_after_seconds(10) .await?; diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 0925a9f377..21fd4f8a7a 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -15,8 +15,8 @@ use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transfor use crate::js_eval::{eval_timeout, IdContext}; use crate::worker_utils::get_tag_and_concurrency; use crate::{ - JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, UpdateFlow, - KEEP_JOB_DIR, + JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, StepFailureKind, + UpdateFlow, KEEP_JOB_DIR, }; use anyhow::Context; @@ -169,7 +169,7 @@ pub async fn update_flow_status_after_job_completion( canceled_by: Option, result: Arc>, flow_job_duration: Option, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -192,7 +192,7 @@ pub async fn update_flow_status_after_job_completion( stop_early_override, has_triggered_error_handler: false, }; - let mut unrecoverable = unrecoverable; + let mut step_failure = step_failure; loop { potentially_crash_for_testing(); let nrec = match Box::pin(update_flow_status_after_job_completion_internal( @@ -205,7 +205,7 @@ pub async fn update_flow_status_after_job_completion( rec.canceled_by, rec.flow_job_duration.clone(), rec.result, - unrecoverable, + step_failure, same_worker_tx, worker_dir, rec.stop_early_override, @@ -234,7 +234,7 @@ pub async fn update_flow_status_after_job_completion( Arc::new(to_raw_value(&Json(&WrappedError { error: json!(e.to_string()), }))), - true, + StepFailureKind::Unrecoverable, same_worker_tx, worker_dir, rec.stop_early_override, @@ -249,7 +249,7 @@ pub async fn update_flow_status_after_job_completion( .await? } }; - unrecoverable = false; + step_failure = StepFailureKind::Normal; match nrec { UpdateFlowStatusAfterJobCompletion::Done(job) => { @@ -421,7 +421,7 @@ pub async fn update_flow_status_after_job_completion_internal( canceled_by: Option, mut flow_job_duration: Option, result: Arc>, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -1298,11 +1298,11 @@ pub async fn update_flow_status_after_job_completion_internal( }), ) } else { - // An unrecoverable failure (worker crash/OOM) must reach the error handler - // even on a continue_on_error step, so don't advance the step counter past - // the failed module — otherwise the flow would silently continue to the next - // step and hide the worker death. - let inc = if !unrecoverable && continue_on_error { + // A failure the module's error policy does not describe must reach the error + // handler even on a continue_on_error step, so don't advance the step + // counter past the failed module — otherwise the flow would silently + // continue to the next step and hide it. + let inc = if step_failure.honors_step_error_policy() && continue_on_error { let retry = current_module .as_ref() .and_then(|x| x.retry.clone()) @@ -1741,19 +1741,20 @@ pub async fn update_flow_status_after_job_completion_internal( // enclosing job/subflow. Detect that case and treat the flow as successful. let recoverable_failure_at_last_step = !success && is_last_step - && !unrecoverable + && step_failure.honors_step_error_policy() && (skip_seq_branch_failure || skip_loop_failures || continue_on_error); let should_continue_flow = match success { _ if stop_early => stop_early_err_msg.is_some() && flow_value.failure_module.is_some(), // if stop_early_err_msg some, we want to trigger the error handler before stopping the flow, if any _ if flow_job.is_canceled() => false, true => !is_last_step, - // An unrecoverable failure (a step killed by a worker crash/OOM and surfaced by - // the zombie handler, or an error raised while updating the flow status itself) - // must not be retried or silently skipped, but it should still trigger the flow's - // error handler: an OOM/worker death is precisely when the error handler is expected - // to run. Continue the flow only to reach the failure module, never to retry. - false if unrecoverable => { + // A failure the module's error policy does not describe (a worker crash/OOM + // surfaced by the zombie handler, an error raised while updating the flow status, + // a suspend gate that ended without approval) must not be retried or silently + // skipped, but it should still trigger the flow's error handler — that is + // precisely when the error handler is expected to run. Continue the flow only to + // reach the failure module, never to retry. + false if !step_failure.honors_step_error_policy() => { !is_failure_step && !has_triggered_error_handler && flow_value.failure_module.is_some() @@ -1776,7 +1777,7 @@ pub async fn update_flow_status_after_job_completion_internal( success = true; } - tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, unrecoverable = %unrecoverable, + tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, step_failure = ?step_failure, skip_seq_branch_failure = %skip_seq_branch_failure, skip_loop_failures = %skip_loop_failures, current_module_id = %current_module.map(|x| x.id.clone()).unwrap_or_default(), continue_on_error = %continue_on_error, should_continue_flow = %should_continue_flow, "computed if flow should continue"); @@ -2108,7 +2109,7 @@ pub async fn update_flow_status_after_job_completion_internal( worker_name, flow_runners, &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await @@ -2799,10 +2800,9 @@ pub async fn handle_flow( worker_name: &str, flow_runners: Option>, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The previous step failed unrecoverably (e.g. a worker crash/OOM surfaced by the - // zombie handler). The next pushed step can only be the error handler (failure - // module), and it must not be pinned to the dead worker via same_worker. - unrecoverable: bool, + // How the step this flow is resuming from failed, which bounds what may be pushed next: + // see [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> anyhow::Result<()> { let flow = flow_data.value(); @@ -2984,7 +2984,7 @@ pub async fn handle_flow( flow_runners.clone(), job_completed_tx.clone(), &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await?; @@ -3167,10 +3167,9 @@ async fn push_next_flow_job( flow_runners: Option>, job_completed_tx: JobCompletedSender, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The prior step failed unrecoverably (worker crash/OOM). The only step pushed - // from here is the error handler, which must run on a live worker rather than - // being pinned to the dead one via same_worker / dedicated runners. - unrecoverable: bool, + // How the prior step failed, which bounds what may be pushed next: see + // [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> error::Result { let job_root = flow_job .flow_innermost_root_job @@ -3231,6 +3230,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + step_failure, }))); } @@ -3285,6 +3285,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + step_failure, } ))); } @@ -3329,6 +3330,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + step_failure, }))); } } @@ -3661,6 +3663,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), + step_failure: StepFailureKind::SuspendNotApproved, }))); } } @@ -3730,10 +3733,11 @@ async fn push_next_flow_job( } }; - // An unrecoverable failure (worker crash/OOM) must not be retried — the original worker - // and its state are gone — so skip retry evaluation and fall straight through to the - // failure module below. - let retry = if !unrecoverable && matches!(&status_module, FlowStatusModule::Failure { .. },) { + // Retry is a policy on the step's own execution: skip it for a failure the step did not + // produce by running, and fall straight through to the failure module below. + let retry = if step_failure.honors_step_error_policy() + && matches!(&status_module, FlowStatusModule::Failure { .. },) + { let retry = &module.retry.clone().unwrap_or_default(); evaluate_retry( retry, @@ -3748,13 +3752,13 @@ async fn push_next_flow_job( None }; let get_args_from_id = match &status_module { - // `|| unrecoverable`: a worker crash/OOM routes to the failure module even on a - // continue_on_error step (whose failures are normally tolerated), matching the - // `unrecoverable` decision in update_flow_status_after_job_completion_internal. + // `|| !honors_step_error_policy()`: such a failure routes to the failure module even + // on a continue_on_error step (whose failures are normally tolerated), matching the + // decision in update_flow_status_after_job_completion_internal. FlowStatusModule::Failure { job, .. } if retry.as_ref().is_some() || !module.continue_on_error.is_some_and(|x| x) - || unrecoverable => + || !step_failure.honors_step_error_policy() => { if let Some((fail_count, retry_in)) = retry { tracing::debug!( @@ -4075,7 +4079,7 @@ async fn push_next_flow_job( None, result, None, - false, + StepFailureKind::Normal, same_worker_tx, worker_dir, None, @@ -4103,7 +4107,7 @@ async fn push_next_flow_job( .as_ref() .is_some_and(|fr| fr.job_id == flow_job.id); - let continue_with_runners = !unrecoverable + let continue_with_runners = step_failure.keeps_worker_pin() && (start_runners || (flow_runners.is_some() && !do_not_pass_runners)) && module.suspend.is_none() && module.sleep.is_none(); @@ -4113,10 +4117,10 @@ async fn push_next_flow_job( let job_same_worker = flow_job.same_worker && matches!(flow_job.kind, JobKind::Flow) && flow_job.runnable_id.is_some(); - // After an unrecoverable failure the original worker is gone, so the error handler - // step is pushed as a regular queued job (any live worker can pick it up) instead of - // being signaled to the dead worker via same_worker — which would strand it forever. - let continue_on_same_worker = !unrecoverable + // Without a worker worth pinning to, the error handler step is pushed as a regular queued + // job (any live worker can pick it up) instead of being signaled via same_worker to a + // worker that may be dead — which would strand it forever. + let continue_on_same_worker = step_failure.keeps_worker_pin() && (flow.same_worker || job_same_worker) && module.suspend.is_none() && module.sleep.is_none(); diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 8e229cae4e..0ce64d88a3 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.780.0"; +export const VERSION = "v1.782.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/.gitignore b/cli/.gitignore index e6c9bd2655..91d60ec213 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,4 +1,8 @@ npm/ gen/ node_modules/ -windmill-utils-internal/.npmrc \ No newline at end of file +windmill-utils-internal/.npmrc + +# bun.lock is the CLI's lockfile: every install path here (CI, build.sh, install_dev.sh) +# runs `bun install`. A committed npm lockfile is read by nothing and goes stale unnoticed. +package-lock.json diff --git a/cli/package-lock.json b/cli/package-lock.json deleted file mode 100644 index 3a9b04c8a2..0000000000 --- a/cli/package-lock.json +++ /dev/null @@ -1,1492 +0,0 @@ -{ - "name": "wmill-dev", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wmill-dev", - "hasInstallScript": true, - "dependencies": { - "@cliffy/ansi": "npm:@jsr/cliffy__ansi@1.0.0", - "@cliffy/command": "npm:@jsr/cliffy__command@1.0.0", - "@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0", - "@cliffy/table": "npm:@jsr/cliffy__table@1.0.0", - "@windmill-labs/shared-utils": "^1.0.12", - "diff": "^5.2.0", - "esbuild": "0.28.0", - "get-port": "7.1.0", - "jszip": "3.8.0", - "minimatch": "^10.0.0", - "open": "^10.0.0", - "pg-gateway": "0.3.0-beta.4", - "svelte": "^5.56.8", - "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", - "windmill-parser-wasm-csharp": "1.510.1", - "windmill-parser-wasm-go": "1.761.0", - "windmill-parser-wasm-java": "1.510.1", - "windmill-parser-wasm-nu": "1.510.1", - "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.693.1", - "windmill-parser-wasm-py-imports": "1.693.1", - "windmill-parser-wasm-r": "1.668.1", - "windmill-parser-wasm-regex": "1.764.0", - "windmill-parser-wasm-ruby": "1.526.1", - "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-yaml": "1.593.0", - "ws": "8.18.0", - "yaml": "^2.7.0" - }, - "bin": { - "wmill": "src/main.ts" - }, - "devDependencies": { - "@types/bun": "^1.3.9", - "@types/diff": "^5.2.3", - "@types/node": "^22.0.0", - "@types/tar-stream": "^3.1.4", - "@types/ws": "^8.5.0", - "ajv": "^8.17.1", - "typescript": "^5.7.0" - } - }, - "node_modules/@cliffy/ansi": { - "name": "@jsr/cliffy__ansi", - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", - "integrity": "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg==", - "dependencies": { - "@jsr/cliffy__internal": "1.0.0", - "@jsr/std__encoding": "^1.0.10", - "@jsr/std__fmt": "^1.0.9", - "@jsr/std__io": "~0.225.3" - } - }, - "node_modules/@cliffy/command": { - "name": "@jsr/cliffy__command", - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__command/1.0.0.tgz", - "integrity": "sha512-oObplVtu1tvpkhgpuPDHZidx9g3axVOfRMQGmw7ZSGxp0+vZIJGiEtpcSvlN0XfuEhOG8neqfVBSSE9txrKanw==", - "dependencies": { - "@jsr/cliffy__flags": "1.0.0", - "@jsr/cliffy__internal": "1.0.0", - "@jsr/cliffy__table": "1.0.0", - "@jsr/std__fmt": "^1.0.9", - "@jsr/std__semver": "^1.0.8", - "@jsr/std__text": "^1.0.17" - } - }, - "node_modules/@cliffy/prompt": { - "name": "@jsr/cliffy__prompt", - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__prompt/1.0.0.tgz", - "integrity": "sha512-JDuHcCAjScV0IUj389brneF6AzJyyP0pK8mymsrGN5/PGQfqK8zr96QpFlo1wmo8BY/3JQAdNfy6NZkPCJ6VWA==", - "dependencies": { - "@jsr/cliffy__ansi": "1.0.0", - "@jsr/cliffy__internal": "1.0.0", - "@jsr/cliffy__keycode": "1.0.0", - "@jsr/std__assert": "^1.0.18", - "@jsr/std__fmt": "^1.0.9", - "@jsr/std__io": "~0.225.3", - "@jsr/std__path": "^1.1.4", - "@jsr/std__text": "^1.0.17" - } - }, - "node_modules/@cliffy/table": { - "name": "@jsr/cliffy__table", - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", - "integrity": "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ==", - "dependencies": { - "@jsr/std__fmt": "^1.0.9" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsr/cliffy__ansi": { - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", - "integrity": "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg==", - "dependencies": { - "@jsr/cliffy__internal": "1.0.0", - "@jsr/std__encoding": "^1.0.10", - "@jsr/std__fmt": "^1.0.9", - "@jsr/std__io": "~0.225.3" - } - }, - "node_modules/@jsr/cliffy__flags": { - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__flags/1.0.0.tgz", - "integrity": "sha512-j/v3J8MWu0tkYyisZ2w1HxELxxL/qg6vey9+fRkbTJ+S9J0GeLUn2joouikG7aXpULKCXHTjJ9XH9gQx+F3npw==", - "dependencies": { - "@jsr/cliffy__internal": "1.0.0", - "@jsr/std__text": "^1.0.17" - } - }, - "node_modules/@jsr/cliffy__internal": { - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__internal/1.0.0.tgz", - "integrity": "sha512-YPkbccbuu+kE55k+nia5jJx5Tu/IolBDXZTAgEA+YRGOzq8I1VkXajwykFXvSbXeVee3zQBU7y0HajVDB7ujQA==", - "dependencies": { - "@jsr/std__fmt": "^1.0.9" - } - }, - "node_modules/@jsr/cliffy__keycode": { - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__keycode/1.0.0.tgz", - "integrity": "sha512-1ot+y8oZheBTpfgCazWjSOAK2Y2nOQD7NwMuiSAkcRuc1t7VizQZfDpZtBx97NlkYWgjn6ylArt2xyhiyLKRhA==" - }, - "node_modules/@jsr/cliffy__table": { - "version": "1.0.0", - "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", - "integrity": "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ==", - "dependencies": { - "@jsr/std__fmt": "^1.0.9" - } - }, - "node_modules/@jsr/std__assert": { - "version": "1.0.19", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz", - "integrity": "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA==", - "dependencies": { - "@jsr/std__internal": "^1.0.12" - } - }, - "node_modules/@jsr/std__bytes": { - "version": "1.0.6", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz", - "integrity": "sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==" - }, - "node_modules/@jsr/std__encoding": { - "version": "1.0.10", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz", - "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==" - }, - "node_modules/@jsr/std__fmt": { - "version": "1.0.9", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz", - "integrity": "sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw==" - }, - "node_modules/@jsr/std__internal": { - "version": "1.0.12", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz", - "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==" - }, - "node_modules/@jsr/std__io": { - "version": "0.225.3", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__io/0.225.3.tgz", - "integrity": "sha512-IDXY253ipW6FV34CJVxO+3ubfvSEEzw9N2W303KnLe9K/Y9+v/ID1dQYf9VsCCOFMpFtCmOLqzIZsRqv6yQnWw==", - "dependencies": { - "@jsr/std__bytes": "^1.0.6" - } - }, - "node_modules/@jsr/std__path": { - "version": "1.1.4", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz", - "integrity": "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==", - "dependencies": { - "@jsr/std__internal": "^1.0.12" - } - }, - "node_modules/@jsr/std__regexp": { - "version": "1.0.1", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__regexp/1.0.1.tgz", - "integrity": "sha512-AnGeP//DHpPvhCWjI5dR4o013JhCQioD8yMF8drD7PWb0X4kvmO35hbZi+NZhfSolz4Ts2cpPzJY+DUpi2XE9A==" - }, - "node_modules/@jsr/std__semver": { - "version": "1.0.8", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__semver/1.0.8.tgz", - "integrity": "sha512-YhkykPU2Majz66e+rQbP0okYc7kKv+U32aguLPCXZZAL+vEVmBA+khHjPHhLBpWR073gzU3WHqGRgB7a/aXCjg==" - }, - "node_modules/@jsr/std__text": { - "version": "1.0.17", - "resolved": "https://npm.jsr.io/~/11/@jsr/std__text/1.0.17.tgz", - "integrity": "sha512-oZsihl1bcTy1Ixzven8rin8kjChj1zDJWqgpS0oSMGCJDzyB365gtIfAvcMmji+M+FcIWo3goDXfHcFYt+k/kg==", - "dependencies": { - "@jsr/std__regexp": "^1.0.1" - } - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bun-types": "1.3.9" - } - }, - "node_modules/@types/diff": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", - "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/tar-stream": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz", - "integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@windmill-labs/shared-utils": { - "name": "@jsr/windmill-labs__shared-utils", - "version": "1.0.12", - "resolved": "https://npm.jsr.io/~/11/@jsr/windmill-labs__shared-utils/1.0.12.tgz", - "integrity": "sha512-bJOacyfxxNPwNTzA4AxCB5iGFop0h3mCgs+E9j3ZaJYDo1soblY16CebnQ56EPy/M3V344X/QoOFBORyRo1Mnw==" - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/devalue": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", - "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", - "license": "MIT" - }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", - "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/jszip": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.8.0.tgz", - "integrity": "sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "set-immediate-shim": "~1.0.1" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/pg-gateway": { - "version": "0.3.0-beta.4", - "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz", - "integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==", - "license": "MIT" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/set-immediate-shim": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", - "integrity": "sha512-Li5AOqrZWCVA2n5kryzEmqai6bKSIvpz5oUJHPVj6+dsbD3X1ixtsY5tEnsaNpH3pFAHmG8eIHUrtEtohrg+UQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/svelte": { - "version": "5.56.8", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", - "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.12", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/windmill-parser-wasm-asset": { - "version": "1.753.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", - "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" - }, - "node_modules/windmill-parser-wasm-csharp": { - "version": "1.510.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-csharp/-/windmill-parser-wasm-csharp-1.510.1.tgz", - "integrity": "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ==" - }, - "node_modules/windmill-parser-wasm-go": { - "version": "1.761.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-go/-/windmill-parser-wasm-go-1.761.0.tgz", - "integrity": "sha512-jNM0kh0U5uSetwBVSmueFs91GTdbn/tffny2XB1LrrSUElP5i+PK7i12zrWNT2q9JUK2L215CbmyfNmkacbo1Q==" - }, - "node_modules/windmill-parser-wasm-java": { - "version": "1.510.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-java/-/windmill-parser-wasm-java-1.510.1.tgz", - "integrity": "sha512-Zle+JZT/ZwUArUVacUudYlS+CaHp2lSnkqD/IhWaRUG+gcv26VbERnrrHPonqXbVMS+eA9ElfXrFM5j0ukaXUw==" - }, - "node_modules/windmill-parser-wasm-nu": { - "version": "1.510.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-nu/-/windmill-parser-wasm-nu-1.510.1.tgz", - "integrity": "sha512-AJLFiUy6af+LpUe7CddDo4+JOmw3c0K/1iOWh8NdTwXcLDj90lL6089mdsVo1apyloLgrTbcuFDzZMXVGBgtCg==" - }, - "node_modules/windmill-parser-wasm-php": { - "version": "1.647.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-php/-/windmill-parser-wasm-php-1.647.1.tgz", - "integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg==" - }, - "node_modules/windmill-parser-wasm-py": { - "version": "1.693.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.693.1.tgz", - "integrity": "sha512-qSXKxDsKjt1UOEK1NnpKJmDrXIx/sZaGHktC6+Sj6CfhLi1X/Qj37FalOq4Es5l2qzm2NPvk4jkCXsV1QdkcHA==" - }, - "node_modules/windmill-parser-wasm-py-imports": { - "version": "1.693.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.693.1.tgz", - "integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g==" - }, - "node_modules/windmill-parser-wasm-r": { - "version": "1.668.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-r/-/windmill-parser-wasm-r-1.668.1.tgz", - "integrity": "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ==" - }, - "node_modules/windmill-parser-wasm-regex": { - "version": "1.764.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.764.0.tgz", - "integrity": "sha512-V2eFdKD90gqWikOvjl2fwMpFqiFt/21+4iQMbiNJYl7Lm2UiEcEZ4r9bpgJLG4TLOLqvD6+u4Ju3WaytxN2O2w==" - }, - "node_modules/windmill-parser-wasm-ruby": { - "version": "1.526.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ruby/-/windmill-parser-wasm-ruby-1.526.1.tgz", - "integrity": "sha512-rMBQA8s21wmL2kA5ztRs/ZgVA3ckxe9/NLjxl3iQPL0CX6DlvfaUH0O+AnhpXXDMyBs1Y1SZIhcnbnvsHZ3R8g==" - }, - "node_modules/windmill-parser-wasm-rust": { - "version": "1.647.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-rust/-/windmill-parser-wasm-rust-1.647.1.tgz", - "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" - }, - "node_modules/windmill-parser-wasm-ts": { - "version": "1.695.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz", - "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" - }, - "node_modules/windmill-parser-wasm-yaml": { - "version": "1.593.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-yaml/-/windmill-parser-wasm-yaml-1.593.0.tgz", - "integrity": "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw==" - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" - } - } -} diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 171a340184..a28dd0fbd5 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -6071,7 +6071,7 @@ const command = new Command() ) .option( "--dev-workspace-label ", - "Environment label of a dev workspace (dev/staging); its deploys go to that branch", + "Environment label of a dev workspace (dev, staging, uat, ...); its deploys go to that branch", ) .option( "--parent-dev-workspace-label ", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 39c4efa68b..07dc340c6b 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.780.0"; +export const VERSION = "1.782.0"; diff --git a/debugger/Dockerfile b/debugger/Dockerfile index 51e993746a..a80643e823 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -46,7 +46,9 @@ WORKDIR /app # Copy the debug service files COPY dap_debug_service.ts . COPY dap_websocket_server_bun.ts . +COPY env_passthrough.ts . COPY dap_websocket_server.py . +COPY registry_config.ts . # Expose the default port EXPOSE 5679 diff --git a/debugger/README.md b/debugger/README.md index 51c2838f92..1dba49d977 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -75,41 +75,107 @@ Options: | `DAP_NSJAIL_PATH` | nsjail binary path | nsjail | | `DAP_NSJAIL_CONFIG` | nsjail config file path | - | -### Python dependency preparation +### Dependency preparation -Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which -runs `uv` without a database connection. It cannot read the instance settings, so it takes its -registry configuration from the environment of the debug service instead, and the Python server is -handed the resulting venv with `--venv-path`. The install runs in the service rather than in the -session because a private index URL usually embeds credentials and the Python server executes the -debugged script inside its own interpreter, where anything it holds is readable by that script. +Before debugging a script, its imports are installed through `windmill prepare-deps`, which runs +`uv` (Python) or `bun install` (TypeScript) without a database connection. The install runs in the +service rather than in the session because the registry configuration usually embeds credentials +and a debug server executes the submitted script inside a process the script can read; the Python +server is handed only the resulting venv, with `--venv-path`, and a Bun session only the resulting +`node_modules`. -Set these on the debug service. Where two names are listed the first wins; a worker reads the -`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are -only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry -with the other settings): +`DAP_PREPARE_DEPS_TIMEOUT_MS` bounds the install (default 120000); past it the session starts +without its dependencies. When the install fails, the CLI answers `success: false` and carries the +installer's stderr in both `error` and `install_stderr`; the service reports it to the client as an +`output` event, so the reason (unreachable mirror, untrusted certificate, unknown package) reaches +the user instead of a bare `ModuleNotFoundError` at the first import. + +### Registry configuration + +Because `prepare-deps` has no database, the service reads the instance settings for it from +`GET /api/debug/registry_config` on `WINDMILL_BASE_URL` and passes them down over the CLI's stdin +request. It is authorized by the launch token of the session being started, and serves only the +settings that session's own installer runs on, so a TypeScript session's token cannot be used to +read the Python index credentials. + +The token also reaches the browser, so what it can fetch is what a workspace member can fetch. +Sessions started by an operator are refused outright, since an operator cannot run a preview job +either; for a member who can, the npm settings are already exposed by a preview (a worker leaves +the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in), while the Python +index URL, which otherwise only appears as uv's argv, becomes readable where it was not before. + +These settings are Enterprise-only, exactly as they are for jobs, and a CE instance reports that in +the session's output rather than applying them: + +| Setting | Applies to | +|---------|------------| +| `npm_config_registry` | `bun install` registry and its `:_authToken=` | +| `npmrc` | written verbatim as `.npmrc`, taking precedence over `npm_config_registry` | +| `bunfig_install_scopes` | `[install.scopes]` in the generated `bunfig.toml` | +| `pip_index_url` | `uv --index-url` | +| `pip_extra_index_url` | `uv --extra-index-url`, comma-separated | + +`uv_index_strategy` is served on any edition, like it is to a worker. An index URL holding the +`EPHEMERAL_TOKEN` placeholder is not served at all: only a worker can run the command that +substitutes it. + +The credential-bearing files (`.npmrc`, `bunfig.toml`) are written under +`/var/tmp/windmill-debug-registry`, not into the directory the install runs in, and are deleted +when the install ends. That directory is not private to the install: a session resolves its +`node_modules` symlink back into it, and `nsjail.debug.config.proto` bind-mounts the whole of +`/tmp` into every session, so credentials left there would be readable by a concurrent session. +`/var/tmp` is a tmpfs in that same config, one instance per jail, so a session sees an empty one +and a jailed install's credentials go away with the jail even when it is killed (the service kills +an install with SIGKILL, which no cleanup in the installer can survive). An install running +unjailed writes to the host's `/var/tmp` instead, where a directory a kill left behind is removed +by the next install; a session running unjailed is unconfined anyway and sees the whole filesystem, +as it already does the rest of the service's state. + +The rest of the registry configuration has no instance setting and is read from the environment of +the debug service. Where two names are listed the first wins; a worker reads the same names: | Variable | Description | Default | |----------|-------------|---------| -| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI | -| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - | | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | -| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - | +| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | +| `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | | `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | -| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | -| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 | +| `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | -When the install fails, the CLI answers `success: false` and carries the installer's stderr in both -`error` and `install_stderr`; the service reports it to the client as an `output` event, so the -reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a -bare `ModuleNotFoundError` at the first import. +`PY_INDEX_URL` / `PIP_INDEX_URL` and `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL`, along with +`UV_INDEX_STRATEGY`, are still read from the same environment whenever the fetch yields no index: +because the instance has none set, because this is a CE instance, or because the session was not +allowed the settings. A Python debug service configured that way therefore keeps working, but setting +them is an instance-wide decision to install Python dependencies from that index, independent of who +opened the session; leave them unset to let the instance settings alone decide. The npm settings have +no such fallback: the instance settings are the only source. Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the service into each session, since the debugged script needs them for its own outbound calls, exactly as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied. +Trust roots are forwarded alongside them: `SSL_CERT_FILE`, `SSL_CERT_DIR`, `REQUESTS_CA_BUNDLE`, +`CURL_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`. Behind a TLS-intercepting proxy these are what let the +debugged script's own HTTPS calls verify, and installing the CA in the container's system store is +not enough on its own, since `requests` carries its own bundle and Node reads only +`NODE_EXTRA_CA_CERTS`. Registry settings are deliberately not forwarded: they carry credentials and +only the service needs them. + +Registering that CA in the container's system store happens on its own: mount it into +`/usr/local/share/ca-certificates/` **named `*.crt`**, the only extension `update-ca-certificates` +reads, and `windmill_extra` runs it before starting any service. `RUN_UPDATE_CA_CERTIFICATE_AT_START=true` forces the same thing whether or not +certificates are mounted there, and `RUN_UPDATE_CA_CERTIFICATE_PATH` overrides the tool, matching +the server and worker. Both are best-effort: a UID that cannot write `/etc/ssl/certs` logs a warning +and the container still boots. `INIT_SCRIPT` remains the hook for anything more involved, and unlike +the CA update it aborts startup when it fails. + +Note what the system store does *not* cover, which is most of what a debug session installs with: +uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read +only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib +`ssl`, `curl` and `git`; the rest still needs the variables above. + Keeping the settings out of the session's environment only bounds what the debugged script can read from itself. An unsandboxed session runs under the same user as the service and can still read the service's environment through `/proc`, the same way a job can read a worker's when the worker runs @@ -117,6 +183,14 @@ unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service out of reach, not the flag on its own. +The installer is jailed on the same terms, in both languages: `uv pip install` builds source +distributions and `bun install` runs postinstall scripts, so a package's own code executes there +too. It keeps the service's environment across that boundary — the config sets `keep_env`, which is +how the settings above reach it — so replacing that with an allowlist would have to carry the +registry and CA variables in explicitly. It also runs in its own process group, because `uv` and +`bun` are grandchildren: signalling only the installer reparents them to init and they keep +downloading, which would make the timeout and the cancel-on-disconnect half-measures. + ### Frontend Integration ```svelte diff --git a/debugger/dap_debug_service.ts b/debugger/dap_debug_service.ts index 405cf15fde..11616f7b11 100644 --- a/debugger/dap_debug_service.ts +++ b/debugger/dap_debug_service.ts @@ -40,7 +40,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' // Import the working Bun debug session from the standalone server -import { DebugSession as BunDebugSessionWorking, type NsjailConfig } from './dap_websocket_server_bun' +import { + DebugSession as BunDebugSessionWorking, + killProcessTree, + nsjailWrap, + type NsjailConfig +} from './dap_websocket_server_bun' +import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // ============================================================================ // Configuration @@ -348,78 +355,35 @@ interface SpawnOptions { cmd: string[] cwd?: string env?: Record + stdin?: Blob + /** + * Hand the child this process's whole environment rather than the minimal set below. Only + * the dependency installer wants it: the registry credentials and CA settings it reads are + * precisely what the minimal set exists to keep away from a debugged script. + */ + inheritEnv?: boolean + /** + * Start the child in its own process group, so killProcessTree can signal what it spawns. + */ + detached?: boolean stdout?: 'pipe' | 'inherit' stderr?: 'pipe' | 'inherit' } -/** - * Proxy settings forwarded to a debug session, matching what a worker gives a job's script. - * spawnProcess intentionally does not inherit this process's environment, so an outbound proxy - * is unreachable from a session unless these are passed explicitly. Registry settings are - * deliberately absent: they carry credentials and are consumed by the service itself (see - * PythonDebugSession.prepareDependencies). - */ -const SESSION_PROXY_ENV_VARS = [ - 'HTTP_PROXY', - 'HTTPS_PROXY', - 'NO_PROXY', - // The lowercase spellings take precedence in the worker, so forward both. - 'http_proxy', - 'https_proxy', - 'no_proxy' -] - /** * How long `windmill prepare-deps` may take before the session gives up on it and starts without * the dependencies. Raise it for slow private mirrors, where a large install can outlast the default. */ const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000 -function sessionProxyEnv(): Record { - const env: Record = {} - for (const key of SESSION_PROXY_ENV_VARS) { - const value = process.env[key] - if (value) { - env[key] = value - } - } - // A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it; - // the worker defaults the same way (PROXY_ENVS in windmill-worker). - if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) { - env.NO_PROXY = 'localhost,127.0.0.1' - } - return env -} - /** * Spawn a process, optionally wrapped with nsjail. * This is the key function for sandboxed execution. */ function spawnProcess(options: SpawnOptions): Subprocess { - let cmd = options.cmd + const cmd = nsjailWrap(options.cmd, config.nsjail, options.cwd) if (config.nsjail.enabled) { - // Build nsjail command - const nsjailCmd = [config.nsjail.binaryPath] - - // Add config file if specified - if (config.nsjail.configPath) { - nsjailCmd.push('--config', config.nsjail.configPath) - } - - // Add any extra nsjail arguments - nsjailCmd.push(...config.nsjail.extraArgs) - - // Add working directory if specified - if (options.cwd) { - nsjailCmd.push('--cwd', options.cwd) - } - - // Separator and actual command - nsjailCmd.push('--') - nsjailCmd.push(...cmd) - - cmd = nsjailCmd logger.info(`Spawning with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Spawning: ${cmd.join(' ')}`) @@ -430,9 +394,12 @@ function spawnProcess(options: SpawnOptions): Subprocess { return spawn({ cmd, cwd: options.cwd || process.cwd(), + ...(options.stdin ? { stdin: options.stdin } : {}), + ...(options.detached ? { detached: true } : {}), stdout: options.stdout || 'pipe', stderr: options.stderr || 'pipe', env: { + ...(options.inheritEnv ? process.env : {}), // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, @@ -551,6 +518,8 @@ class PythonDebugSession extends BaseDebugSession { private envVars: Record = {} private windmillPath?: string private venvPath?: string + private prepareDepsProcess: Subprocess | null = null + private disposed = false private debugMode: boolean constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) { @@ -682,21 +651,29 @@ class PythonDebugSession extends BaseDebugSession { * Install the script's imports through `windmill prepare-deps` and return the venv to add to * the debugged script's sys.path. * - * This runs here rather than in the Python server because the registry settings the CLI reads - * (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python - * server executes the submitted script inside its own interpreter: anything in that process is - * recoverable by the script. The service never executes user code, so the credentials stop here. + * This runs here rather than in the Python server because the registry settings the CLI is + * given routinely embed private-registry credentials, and the Python server executes the + * submitted script inside its own interpreter: anything in that process is recoverable by the + * script. The service never executes user code, so the credentials stop here. * - * The trade-off is that the install itself is not jailed, so a source distribution's build - * backend runs outside nsjail, as it already does for Bun sessions. + * It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee: + * `uv pip install` builds source distributions, which executes their build backend's arbitrary + * Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the + * environment across the boundary, so nothing else has to carry them in. */ - private async prepareDependencies(code: string): Promise { + private async prepareDependencies(code: string, registry: RegistryConfig): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null } const warn = (reason: string): null => { + // cleanup() kills the installer, which ends the read with nothing to parse. Reporting + // that as an install failure blames the user for their own disconnect, on a websocket + // that is being torn down anyway. + if (this.disposed) { + return null + } logger.error(`prepare-deps failed: ${reason}`) this.sendEvent('output', { category: 'stderr', @@ -706,21 +683,33 @@ class PythonDebugSession extends BaseDebugSession { } try { - const proc = spawn({ + const proc = spawnProcess({ cmd: [this.windmillPath, 'prepare-deps'], - stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']), - stdout: 'pipe', - stderr: 'pipe' + // The venv has to be built against the interpreter that will run the script: its + // site-packages goes on that interpreter's sys.path, and uv otherwise picks its + // own, which silently leaves compiled extensions unimportable. + stdin: new Blob([ + JSON.stringify({ + code, + language: 'python3', + python_path: config.pythonPath, + registry + }) + '\n' + ]), + inheritEnv: true, + detached: true }) + this.prepareDepsProcess = proc // The launch response is already sent, so an install that never returns would leave the // client waiting on a session that never starts, with nothing on screen. The deadline // races the read rather than only killing the child: a grandchild holding the pipe open // keeps the read pending long after the child itself is gone. let timer: ReturnType | undefined + // spawnProcess's return type does not carry the piped stdio through const read = (async () => ({ - output: await new Response(proc.stdout).text(), - stderr: await new Response(proc.stderr).text() + output: await new Response(proc.stdout as ReadableStream).text(), + stderr: await new Response(proc.stderr as ReadableStream).text() }))() const result = await Promise.race([ read, @@ -729,9 +718,10 @@ class PythonDebugSession extends BaseDebugSession { }) ]) clearTimeout(timer) + this.prepareDepsProcess = null if (!result) { - proc.kill() + killProcessTree(proc) return warn( `dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` ) @@ -797,7 +787,7 @@ class PythonDebugSession extends BaseDebugSession { this.process = spawnProcess({ cmd, cwd, - env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars } + env: { PYTHONUNBUFFERED: '1', ...sessionEnv(), ...this.envVars } }) // Read stderr to capture startup messages @@ -1047,6 +1037,10 @@ class PythonDebugSession extends BaseDebugSession { } private async handleLaunch(request: DAPMessage): Promise { + // Per launch, not per session: cleanup() also runs when a program finishes normally, and + // the flag must only mean "torn down while this launch was still preparing". + this.disposed = false + const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined @@ -1054,6 +1048,8 @@ class PythonDebugSession extends BaseDebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1067,7 +1063,6 @@ class PythonDebugSession extends BaseDebugSession { return } - const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') @@ -1130,7 +1125,29 @@ sys.stdout.flush() try { if (code) { - this.venvPath = (await this.prepareDependencies(code)) ?? undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs + // a source distribution's build backend, so starting one for a session that is + // already gone executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session torn down during the registry configuration fetch, not installing') + await this.cleanup() + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined + } + + // Installing takes long enough for the client to give up meanwhile, and cleanup() has + // then already run: starting the debuggee now would leave a process nothing owns + // executing the script for a session that is gone. Clean up again on the way out, + // since a teardown that landed before the script was written left it behind. + if (this.disposed) { + logger.info('Session torn down during dependency preparation, not starting Python') + await this.cleanup() + return } await this.startPythonProcess(cwd) @@ -1188,6 +1205,14 @@ sys.stdout.flush() } async cleanup(): Promise { + this.disposed = true + + // A client that gives up mid-install must not leave the package manager running + if (this.prepareDepsProcess) { + killProcessTree(this.prepareDepsProcess) + this.prepareDepsProcess = null + } + if (this.debugpyWs) { this.debugpyWs.close() this.debugpyWs = null diff --git a/debugger/dap_websocket_server_bun.ts b/debugger/dap_websocket_server_bun.ts index ff060f3da6..3b5072b1e6 100644 --- a/debugger/dap_websocket_server_bun.ts +++ b/debugger/dap_websocket_server_bun.ts @@ -22,9 +22,12 @@ */ import { spawn, type Subprocess } from 'bun' +import { readFileSync } from 'node:fs' import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { sessionEnv } from './env_passthrough' +import { fetchRegistryConfig, type RegistryConfig } from './registry_config' // Types for V8 Inspector Protocol interface V8Message { @@ -556,6 +559,63 @@ export interface NsjailConfig { extraArgs?: string[] } +/** + * Wrap a command so nsjail runs it, or return it unchanged when sandboxing is off. + * The environment is not filtered here: the config sets `keep_env`, so the jailed process + * receives whatever the spawning call gives it. + */ +export function nsjailWrap(cmd: string[], nsjail: NsjailConfig | undefined, cwd?: string): string[] { + if (!nsjail?.enabled) { + return cmd + } + const wrapped = [nsjail.binaryPath] + if (nsjail.configPath) { + wrapped.push('--config', nsjail.configPath) + } + if (nsjail.extraArgs) { + wrapped.push(...nsjail.extraArgs) + } + if (cwd) { + wrapped.push('--cwd', cwd) + } + wrapped.push('--', ...cmd) + return wrapped +} + +/** + * SIGKILL a subprocess along with everything it spawned. + * + * SIGKILL because `windmill prepare-deps` does not act on SIGTERM while uv is running. The + * whole group because uv is a grandchild: signalling the child alone reparents uv to init and + * it keeps downloading. The group id is read back from /proc instead of assumed, since a group + * kill aimed at this service's own group would take down every service in the container; a + * child spawned without `detached` therefore only gets the plain kill. + */ +export function killProcessTree(proc: Subprocess): void { + if (proc.exitCode !== null || proc.signalCode !== null) { + // Nothing left to signal, and the pid may already have been handed to someone else + return + } + + let ownsGroup = false + try { + const stat = readFileSync(`/proc/${proc.pid}/stat`, 'utf8') + // The comm field can hold spaces and parentheses, so read the fields after its closing one + ownsGroup = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]) === proc.pid + } catch { + // Already reaped, or not Linux: fall back to killing the process alone + } + try { + if (ownsGroup) { + process.kill(-proc.pid, 'SIGKILL') + } else { + proc.kill('SIGKILL') + } + } catch (error) { + logger.error('Failed to kill process:', error) + } +} + /** * VLQ (Variable-Length Quantity) decoder for source maps. * Returns array of decoded integers from VLQ string. @@ -753,6 +813,11 @@ export class DebugSession { // Path to installed node_modules (set after prepare-deps runs) private nodeModulesPath?: string + // Running dependency installer, so a teardown mid-install can stop it + private prepareDepsProcess: Subprocess | null = null + + private disposed = false + constructor(ws: WebSocket, options?: { nsjailConfig?: NsjailConfig; bunPath?: string; windmillPath?: string }) { this.ws = ws this.nsjailConfig = options?.nsjailConfig @@ -1437,6 +1502,10 @@ export class DebugSession { * Handle the 'launch' request. */ async handleLaunch(request: DAPMessage): Promise { + // Per launch, not per session: cleanup() also runs when a program finishes normally, and + // the flag must only mean "torn down while this launch was still preparing". + this.disposed = false + const args = request.arguments || {} let code = args.code as string | undefined this.scriptPath = args.program as string | undefined @@ -1444,6 +1513,8 @@ export class DebugSession { this.callMain = (args.callMain as boolean) || false this.mainArgs = (args.args as Record) || {} this.envVars = (args.env as Record) || {} + // Also what authorizes the registry configuration fetch below. + const token = args.token as string | undefined // Enforce signing on every launch. The token is passed in the launch // arguments and is verified against the inline `code` (see windmill-api-debug). @@ -1457,7 +1528,6 @@ export class DebugSession { return } - const token = args.token as string | undefined if (!token) { logger.error('No debug token provided but signed requests are required') this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.') @@ -1489,7 +1559,29 @@ export class DebugSession { // Prepare dependencies using the original code (before any modifications) // This analyzes imports and installs required npm packages if (code) { - this.nodeModulesPath = await this.prepareDependencies(code) || undefined + const registry = await fetchRegistryConfig(token, logger) + // A round trip of its own, during which the client can give up: the installer runs the + // packages' postinstall scripts, so starting one for a session that is already gone + // executes package code nobody is waiting for. + if (this.disposed) { + logger.info('Session was torn down during the registry configuration fetch, not installing') + this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') + return + } + if (registry.message) { + this.sendEvent('output', { category: 'console', output: `${registry.message}\n` }) + } + this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined + + // Installing takes long enough for the client to give up meanwhile, and cleanup() has + // then already run: starting the debuggee now would leak a process nothing owns. + // The response still goes out, since a client that terminated without closing the + // socket is otherwise left waiting out its own launch timeout. + if (this.disposed) { + logger.info('Session was torn down during dependency preparation, not starting Bun') + this.sendResponse(request, false, {}, 'Session terminated during dependency preparation') + return + } // Remove version specifiers from imports (e.g., "lodash@4" -> "lodash") // This must happen AFTER prepareDependencies (which needs the versions) @@ -1572,8 +1664,21 @@ export class DebugSession { * Prepare dependencies by calling the windmill CLI's prepare-deps command. * This analyzes imports in the code and installs required npm packages. * Returns the path to node_modules if any were installed. + * + * Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall + * scripts, which is user-supplied code executing next to the other services in the container. + * Its environment is inherited rather than filtered, which is what carries the CA settings + * into the installer (the jail keeps the environment across the boundary). + * + * The CLI has no database, so `registry` carries the instance's registry settings down to it + * instead. They configure `bun install` and nothing else: the debugged script never gets + * them, since it could read them back out of the process it runs in. */ - private async prepareDependencies(code: string, language: string = 'bun'): Promise { + private async prepareDependencies( + code: string, + registry: RegistryConfig, + language: string = 'bun' + ): Promise { if (!this.windmillPath) { logger.info('No windmill binary path configured, skipping dependency preparation') return null @@ -1596,29 +1701,43 @@ export class DebugSession { let timedOut = false try { - const input = JSON.stringify({ code, language }) + '\n' + const input = JSON.stringify({ code, language, registry }) + '\n' logger.info(`prepare-deps input length: ${input.length}`) - // Spawn the windmill binary with prepare-deps command + // Spawn the windmill binary with prepare-deps command. Its environment is inherited + // rather than filtered, which is what gives prepare-deps the container's index and + // certificate settings; the allowlist above is what keeps them from the debugged + // script, and the jail keeps them across its own boundary. + const cmd = nsjailWrap([this.windmillPath, 'prepare-deps'], this.nsjailConfig) + logger.info(`Spawning${this.nsjailConfig?.enabled ? ' with nsjail' : ''}: ${cmd.join(' ')}`) const proc = spawn({ - cmd: [this.windmillPath, 'prepare-deps'], + cmd, stdin: new Blob([input]), // Use Blob for complete stdin data stdout: 'pipe', - stderr: 'pipe' + stderr: 'pipe', + // So the installer and the bun it spawns can be killed as one group + detached: true }) + this.prepareDepsProcess = proc // Bound the wait: the only other ceiling is the DAP client's launch timeout, // which is minutes, so a wedged installer would hang the session that long. killTimer = setTimeout(() => { timedOut = true logger.error(`prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS}ms`) - proc.kill() + killProcessTree(proc) }, PREPARE_DEPS_TIMEOUT_MS) // Wait for completion const output = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() + // The read also ends when cleanup() kills the installer, which leaves no output to + // parse. Reporting that as an install failure blames the user for their own Stop. + if (this.disposed) { + return null + } + if (timedOut) { const errorMsg = `prepare-deps timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s` this.sendEvent('output', { @@ -1676,6 +1795,9 @@ export class DebugSession { logger.info('No external dependencies to install') return null } catch (error) { + if (this.disposed) { + return null + } logger.error(`Failed to prepare dependencies: ${error}`) this.sendEvent('output', { category: 'console', @@ -1685,6 +1807,7 @@ export class DebugSession { } finally { clearInterval(progress) clearTimeout(killTimer) + this.prepareDepsProcess = null } } @@ -1700,24 +1823,13 @@ export class DebugSession { const inspectUrl = `127.0.0.1:${inspectPort}` // Build the command - optionally wrapped with nsjail - let cmd: string[] = [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath] + const cmd = nsjailWrap( + [this.bunPath, `--inspect-wait=${inspectUrl}`, this.scriptPath], + this.nsjailConfig, + cwd + ) if (this.nsjailConfig?.enabled) { - const nsjailCmd = [this.nsjailConfig.binaryPath] - - if (this.nsjailConfig.configPath) { - nsjailCmd.push('--config', this.nsjailConfig.configPath) - } - - if (this.nsjailConfig.extraArgs) { - nsjailCmd.push(...this.nsjailConfig.extraArgs) - } - - nsjailCmd.push('--cwd', cwd) - nsjailCmd.push('--') - nsjailCmd.push(...cmd) - - cmd = nsjailCmd logger.info(`Starting Bun with nsjail: ${cmd.join(' ')}`) } else { logger.info(`Starting Bun with --inspect-wait=${inspectUrl}`) @@ -1735,12 +1847,16 @@ export class DebugSession { }, 10000) }) - // Only include essential env vars + client-provided ones + // Only include essential env vars + the network-config allowlist + client-provided ones. // Don't inherit all of process.env to keep debugger environment clean const envVars: Record = { // Essential system vars PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME, + // Proxy / TLS settings inherited from the container, before the client's env so an + // explicit override still wins. Package-index settings are deliberately absent: this + // runs user-supplied code and index URLs carry registry credentials. + ...sessionEnv(), // Client-provided env vars (WM_WORKSPACE, WM_TOKEN, etc.) // Note: WM_BASE_URL is already overridden by BASE_INTERNAL_URL if set ...this.envVars @@ -2490,15 +2606,23 @@ export class DebugSession { } /** - * Clean up resources. + * Clean up resources. Public because both servers call it when a client goes away. */ - private async cleanup(): Promise { + async cleanup(): Promise { + this.disposed = true + // Close inspector connection if (this.inspectorWs) { this.inspectorWs.close() this.inspectorWs = null } + // A disconnect during dependency installation must not leave bun install running + if (this.prepareDepsProcess) { + killProcessTree(this.prepareDepsProcess) + this.prepareDepsProcess = null + } + // Kill process if (this.process) { this.process.kill() @@ -2638,9 +2762,14 @@ if (import.meta.main) { logger.error('Error handling message:', error) } }, - close(ws) { + async close(ws) { logger.info('Client disconnected') - sessions.delete(ws) + const session = sessions.get(ws) + if (session) { + // Dropping the session without this leaves its installer and debuggee running + await session.cleanup() + sessions.delete(ws) + } } } }) diff --git a/debugger/env_passthrough.ts b/debugger/env_passthrough.ts new file mode 100644 index 0000000000..f713483d05 --- /dev/null +++ b/debugger/env_passthrough.ts @@ -0,0 +1,43 @@ +/** + * Container network configuration forwarded to a debug session, matching what a worker gives a + * job's script. The session environment is built from an allowlist rather than inherited, so an + * outbound proxy or a private CA is unreachable from a session unless these are passed + * explicitly. Registry settings are deliberately absent: they carry credentials and are consumed + * by the service itself (see PythonDebugSession.prepareDependencies). + * + * Lives in its own module because both session kinds build their own environment, and + * dap_debug_service.ts already imports from dap_websocket_server_bun.ts. + */ +export const SESSION_ENV_VARS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + // The lowercase spellings take precedence in the worker, so forward both. + 'http_proxy', + 'https_proxy', + 'no_proxy', + // Trust roots for a TLS-intercepting proxy. Installing the CA in the container's system + // store is not enough on its own: requests carries its own bundle and Node reads only + // NODE_EXTRA_CA_CERTS, so a debugged script's own HTTPS calls fail without these. + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', + 'NODE_EXTRA_CA_CERTS' +] + +export function sessionEnv(): Record { + const env: Record = {} + for (const key of SESSION_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + // A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it; + // the worker defaults the same way (PROXY_ENVS in windmill-worker). + if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) { + env.NO_PROXY = 'localhost,127.0.0.1' + } + return env +} diff --git a/debugger/nsjail.debug.config.proto b/debugger/nsjail.debug.config.proto index 5e538e6002..65ea3456cf 100644 --- a/debugger/nsjail.debug.config.proto +++ b/debugger/nsjail.debug.config.proto @@ -63,6 +63,15 @@ mount { rw: true } +# Private scratch, one instance per jail. `windmill prepare-deps` writes the registry +# credentials here rather than into its install directory under the shared /tmp above, so no +# other session can read them, and they go away with the jail even when it is killed. +mount { + dst: "/var/tmp" + fstype: "tmpfs" + rw: true +} + # Debugger scripts directory (for Python debugger server) mount { src: "/debugger" diff --git a/debugger/registry_config.ts b/debugger/registry_config.ts new file mode 100644 index 0000000000..b7cb0451e5 --- /dev/null +++ b/debugger/registry_config.ts @@ -0,0 +1,84 @@ +/** + * Dependency-registry settings for a debug session's install. + * + * `windmill prepare-deps` installs a session's imports with no database connection, so the + * instance settings that point at a private npm or pip registry cannot be read there. They + * are fetched here instead, from the backend that signed the session's launch token, and + * passed down to the CLI over its stdin request. + * + * They stop at the installer. A registry URL usually embeds credentials and a debugged + * script can read whatever the process running it holds, so none of these values are ever + * put in a session's environment (see README.md, "Registry configuration"). + */ + +export interface RegistryConfig { + npm_config_registry?: string + npmrc?: string + bunfig_install_scopes?: string + pip_index_url?: string + pip_extra_index_url?: string + uv_index_strategy?: string + /** Why the instance's settings are not in this response, for the user to see. */ + message?: string +} + +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL + +/** + * Bounds how long a launch waits on the backend. The session can still start without the + * settings, it just installs from the public registries, so an unreachable backend must + * not hold it up for longer than the install itself would take. + */ +const FETCH_TIMEOUT_MS = Number(process.env.DAP_REGISTRY_CONFIG_TIMEOUT_MS) || 10_000 + +/** + * Fetch the registry settings for a session, authorized by its launch token. + * + * Never throws and never blocks a launch: on any failure it returns a config carrying only + * a `message`, so the session starts against the public registries and the user is told why + * instead of being left with an unexplained "package not found". + */ +export async function fetchRegistryConfig( + token: string | undefined, + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void } +): Promise { + if (!token || !WINDMILL_BASE_URL) { + return {} + } + + const url = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/registry_config` + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (response.status === 401 || response.status === 403 || response.status === 404) { + // Expected answers, not something the user can act on: a session that may not read + // the settings (an operator's) is refused, and a backend older than this image has + // no such route at all. Both install from the public registries. + logger.info(`Registry configuration not served for this session (${response.status})`) + return {} + } + if (!response.ok) { + const detail = (await response.text().catch(() => '')).trim() + return { + message: `Could not read the registry configuration (${response.status}): ${detail || response.statusText}` + } + } + + const config: RegistryConfig = await response.json() + // The values carry registry credentials, so only their names are logged. + const configured = Object.entries(config) + .filter(([key, value]) => key !== 'message' && value) + .map(([key]) => key) + logger.info( + configured.length > 0 + ? `Registry configuration from instance settings: ${configured.join(', ')}` + : 'No registry configuration set on the instance' + ) + return config + } catch (error) { + logger.warn(`Failed to fetch registry configuration: ${error}`) + return { message: `Could not read the registry configuration: ${error}` } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 93fbf28e8d..a801a0ce7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -187,6 +187,10 @@ services: # - DEBUG_ALLOWED_ORIGINS=https://your-windmill-host # Optional CSWSH hardening: comma-separated allowlist of browser Origins permitted to open debug WebSockets volumes: - lsp_cache:/pyls/.cache + # Behind a TLS-intercepting proxy, mount its CA here (as .crt) and it is registered in the + # system trust store before any service starts. That alone does not cover dependency + # installation — see debugger/README.md for the variables it also needs + # - ./corp-ca.crt:/usr/local/share/ca-certificates/corp-ca.crt:ro logging: *default-logging caddy: diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index 808bc6ed2a..9eb2a987f2 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -94,7 +94,9 @@ WORKDIR /debugger # Copy debugger files COPY debugger/dap_debug_service.ts . COPY debugger/dap_websocket_server_bun.ts . +COPY debugger/env_passthrough.ts . COPY debugger/dap_websocket_server.py . +COPY debugger/registry_config.ts . COPY debugger/nsjail.debug.config.proto . # Install Python debugger dependencies using uv diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index a06901e25c..b32ad2dd94 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -33,6 +33,58 @@ if [ ! -w "$HOME" ]; then fi export HOME +# Register CA certificates mounted into the image before anything opens a TLS connection. +# Best-effort on purpose, unlike INIT_SCRIPT below: a non-root UID cannot write /etc/ssl/certs, and +# a deployment that never needed a custom CA must still boot. Env var names and the default-off +# behavior match the server/worker binary, so one setting covers every container. What the system +# trust store does and does not reach is documented in debugger/README.md. +CA_CERT_DIR=/usr/local/share/ca-certificates + +update_ca_certificates() { + local reason="$1" + local tool="${RUN_UPDATE_CA_CERTIFICATE_PATH:-/usr/sbin/update-ca-certificates}" + local output + if [ ! -x "$tool" ]; then + echo "[entrypoint] $reason but $tool is not executable, skipping CA update" + return + fi + echo "[entrypoint] $reason, running $tool" + if output=$("$tool" 2>&1); then + echo "[entrypoint] CA certificates updated" + else + # Carry the tool's own message: the usual cause is an unwritable /etc/ssl/certs under a + # non-root UID, but guessing that in place of the real error hides everything else. + echo "[entrypoint] WARNING: $tool failed (UID $(id -u)): ${output:-no output}; continuing" >&2 + fi +} + +if [ "$(echo "${RUN_UPDATE_CA_CERTIFICATE_AT_START:-false}" | tr '[:upper:]' '[:lower:]')" = "true" ]; then + update_ca_certificates "RUN_UPDATE_CA_CERTIFICATE_AT_START=true" +elif [ -n "$(find -L "$CA_CERT_DIR" -type f -name '*.crt' -print -quit 2>/dev/null)" ]; then + # Certificates mounted there are unambiguous intent, and they do nothing until registered, so + # take the same action without making the operator also find the env var. + update_ca_certificates "Found certificates in $CA_CERT_DIR" +elif [ -n "$(ls -A "$CA_CERT_DIR" 2>/dev/null)" ]; then + # Reporting success over a mount update-ca-certificates ignores would be worse than saying + # nothing: .pem is the spelling people reach for, and only .crt is read. + echo "[entrypoint] WARNING: $CA_CERT_DIR has files but none named *.crt, the only extension" \ + "update-ca-certificates reads; they will be ignored" >&2 +fi + +# INIT_SCRIPT is the documented hook for preparing the host before anything reaches the network +# (CA certificates, proxies, mounts), matching the worker's INIT_SCRIPT. It must therefore complete +# before any service starts, and a failure has to abort: services that come up with an unprepared +# trust store fail every TLS handshake instead, which is far harder to diagnose. +if [ -n "$INIT_SCRIPT" ]; then + echo "[entrypoint] Running INIT_SCRIPT..." + bash -c "$INIT_SCRIPT" || { + code=$? + echo "[entrypoint] ERROR: INIT_SCRIPT failed with exit code $code, aborting" >&2 + exit "$code" + } + echo "[entrypoint] INIT_SCRIPT completed" +fi + # Setup NETRC if provided (for LSP) if [ -n "$NETRC" ]; then echo "$NETRC" > "$HOME/.netrc" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e9df072b0e..b058e4f82f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.780.0", + "version": "1.782.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.780.0", + "version": "1.782.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index b70f36508b..ee7bb411b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.780.0", + "version": "1.782.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 249532c916..760f4c0261 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -280,8 +280,9 @@ } /* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both - axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size - via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the + axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size + via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes + `thin` itself and spends ~11px of the box on it. Higher specificity than the app-wide `*::-webkit-scrollbar`, so it overrides it. */ .scrollbar-subtle { scrollbar-width: thin; diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index f900766fb4..28cd3fd17a 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -1,6 +1,7 @@ -{#if isFork} +{#if showBanner}
diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index da89d61f12..3f81c821de 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -37,7 +37,6 @@ import type { PickableProperties } from './flows/previousResults' import { twMerge } from 'tailwind-merge' import FlowPlugConnect from './FlowPlugConnect.svelte' - import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte' import { deepEqual } from 'fast-equals' import S3ArrayHelperButton from './S3ArrayHelperButton.svelte' import { inputBorderClass } from './text_input/TextInput.svelte' @@ -157,15 +156,12 @@ const propPickerWrapperContext: PropPickerWrapperContext | undefined = getContext('PropPickerWrapper') const pickerMode = $derived(propPickerWrapperContext?.pickerMode?.() ?? 'pane') - // Settings rows hand their properties to the wrapper, not to this form. - const connectableProperties = $derived( - pickableProperties ?? propPickerWrapperContext?.pickableProperties?.() - ) const { inputMatches, connectProp: focusProp, propPickerConfig, clearConnect: clearFocus, + openPicker, exprBeingEdited } = propPickerWrapperContext ?? {} @@ -365,6 +361,18 @@ }) } + /** A predicate is usually half-written when you reach for a property, so insert at the + * cursor and leave the rest of the expression alone. Only a field that isn't an + * expression yet gets replaced outright. */ + function pickIntoArg(path: string) { + if (propertyType === 'javascript' && monaco) { + propPickerWrapperContext?.onPick?.(path) + } else { + connectProperty(path) + } + dispatch('change', { argName }) + } + function connectProperty(rawValue: string) { // Extract path from variable('x') or resource('x') format const varMatch = variableMatch(rawValue) @@ -465,8 +473,21 @@ } } + // The column beside a settings row delivers here rather than through the host's `select` + // handler, which can only reach a mounted expression editor. A collapsed setting has no + // field at all, so it gives the target up and the column closes with it. + $effect(() => { + if (pickerMode !== 'sidePane') return + propPickerWrapperContext?.setPickTarget?.( + collapsed ? undefined : { id: argName, onSelect: pickIntoArg } + ) + }) + onDestroy(() => { updatePropsBeingEdited(false) + if (pickerMode === 'sidePane') { + propPickerWrapperContext?.setPickTarget?.(undefined) + } }) let prevArg: any = undefined @@ -555,8 +576,10 @@ {#if (arg != undefined || collapsed) && !hidden}
-
+ to the whole field, letting it share the label's baseline. `w-full` so an + `align-items` on the caller's class can't shrink the row to its label and pull + `right-0` onto it. --> +
@@ -613,27 +636,7 @@ /> {/if} - {#if propPickerWrapperContext && pickerMode === 'popover'} - - { - // A predicate is usually half-written when you reach for a property, so - // insert at the cursor and leave the rest of the expression alone. Only - // a field that isn't an expression yet gets replaced outright. - if (propertyType === 'javascript' && monaco) { - propPickerWrapperContext.onPick?.(path) - } else { - connectProperty(path) - } - dispatch('change', { argName }) - }} - /> - {:else if propPickerWrapperContext} + {#if propPickerWrapperContext} { - connectProperty(path) - dispatch('change', { argName }) + if (pickerMode === 'sidePane') { + pickIntoArg(path) + } else { + connectProperty(path) + dispatch('change', { argName }) + } return true }) } @@ -937,7 +944,12 @@ {/snippet} {:else if argKind === 'javascript' && arg.expr != undefined} + +
openPicker?.()} class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`} > { focused = true updatePropsBeingEdited(true) + openPicker?.() }} on:blur={() => { focused = false diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 7d00731f94..ec9e4b693c 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -21,11 +21,7 @@ import type { SupportedLanguage } from '$lib/common' import { sendUserToast } from '$lib/toast' import { DynamicInput, isScriptPreview } from '$lib/utils' - import { - getActiveRecording, - getActiveReplay, - getReplayStartTime - } from './recording/flowRecording.svelte' + import { getActiveReplay, getReplayStartTime } from './recording/replay.svelte' // Will be set to number if job is not a flow @@ -714,7 +710,6 @@ ) callbacks?.change?.(job) - getActiveRecording()?.recordInitialJob(id, job) } if (!onlyResult) { @@ -824,7 +819,6 @@ throw new Error('Not found') } jobUpdateLastFetch = new Date() - getActiveRecording()?.recordEvent(id, previewJobUpdates) if (job) { updateJobFromProgress(previewJobUpdates, job, callbacks) diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index c767ed835a..8223d3a73d 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -1,4 +1,9 @@ {#if !$userStore?.operator && activeDeployRulesets.length > 0}
- -
- {#if canonicalDev} -

- Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)} - {canonicalDev.name} ({canonicalDev.id}) and promoted here. -

-
- +
+ {:else} +

+ The rule{activeDeployRulesets.length > 1 ? 's' : ''} + {activeDeployRulesets.map((r) => r.name).join(', ')} + restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct edits to this workspace. + {editAdvice} +

+ {/if} + {#if canBypass} + { - if (canonicalDev) switchWorkspace(canonicalDev.id) - }} - > - Go to {devLabelNoun(canonicalDev.dev_workspace_label)} - -
- {:else} -

- The rule{activeDeployRulesets.length > 1 ? 's' : ''} - {activeDeployRulesets.map((r) => r.name).join(', ')} - restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct edits to this workspace. - {editAdvice} -

- {/if} - {#if canBypass} - - {/if} -
- + bind:checked={overrideChecked} + options={{ right: 'Bypass restriction' }} + /> + {/if} +
+ {/snippet} +
{/if} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index dd06a58cde..936d49f3ee 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -125,9 +125,7 @@ import { deepEqual } from 'fast-equals' import { usePreparedAssetSqlQueries } from '$lib/infer.svelte' import { resource, watch } from 'runed' - import { createScriptRecording } from './recording/scriptRecording.svelte' - import { setActiveRecording } from './recording/flowRecording.svelte' - import type { ScriptRecording } from './recording/types' + import { buildScriptRecording, downloadRecordingJson } from './recording/runRecording' import DropdownV2 from './DropdownV2.svelte' interface Props { @@ -743,9 +741,21 @@ let pastPreviewsRequest: ReturnType | undefined let validCode = $state(true) - // Recording - let scriptRecording = createScriptRecording() - let lastRecording: ScriptRecording | undefined = $state(undefined) + // Recording: nothing is captured live — a "record" run just remembers the + // completed job id plus the code/args/schema as they were at run time, and + // the recording is built from the completed job on download. + let recordingArmed = false + let lastRecordingJobId: string | undefined = $state(undefined) + let recordingMeta: + | { + scriptPath: string + code: string + language: string + args: Record + schema?: Record + } + | undefined = undefined + let downloadingRecording = $state(false) let wsProvider: WebsocketProvider | undefined = $state(undefined) let yContent: Y.Text | undefined = $state(undefined) @@ -857,8 +867,8 @@ // keep the latest choice as the active mode. if (opts?.cascade !== undefined) cascadeDownstream = opts.cascade // Discard any previous recording when running a normal test - if (!scriptRecording.active) { - lastRecording = undefined + if (!recordingArmed) { + lastRecordingJobId = undefined } // Not defined if JobProgressBar not loaded jobProgressBar?.reset() @@ -900,19 +910,20 @@ undefined, undefined, { - done(_x) { - if (scriptRecording.active) { - lastRecording = scriptRecording.stop() - setActiveRecording(undefined) + done(x) { + if (recordingArmed) { + recordingArmed = false + lastRecordingJobId = x?.id } if (historyTabActive) { loadPastTests() } }, - doneError({ error }) { - if (scriptRecording.active) { - lastRecording = scriptRecording.stop() - setActiveRecording(undefined) + doneError({ id, error }) { + // A failed run is still a completed job and records fine. + if (recordingArmed) { + recordingArmed = false + lastRecordingJobId = id } console.error(error) } @@ -931,15 +942,31 @@ } async function recordAndTest() { - lastRecording = undefined - scriptRecording.start(path ?? '', code, lang ?? '', args ?? {}, schema) - setActiveRecording(scriptRecording) + lastRecordingJobId = undefined + recordingMeta = { + scriptPath: path ?? '', + code, + language: lang ?? '', + args: JSON.parse(JSON.stringify(args ?? {})), + schema: schema ? JSON.parse(JSON.stringify(schema)) : undefined + } + recordingArmed = true await runTest() } - function downloadRecording() { - if (lastRecording) { - scriptRecording.download(lastRecording) + async function downloadRecording() { + if (!lastRecordingJobId || !recordingMeta || downloadingRecording) return + downloadingRecording = true + try { + const recording = await buildScriptRecording(opWs!, lastRecordingJobId, recordingMeta) + downloadRecordingJson( + recording, + `script-recording-${(recording.script_path || 'untitled').replace(/\//g, '-')}` + ) + } catch (e: any) { + sendUserToast('Could not build the recording', true, undefined, e?.toString()) + } finally { + downloadingRecording = false } } @@ -2014,12 +2041,13 @@ {/if} {/if}
- {#if lastRecording} + {#if lastRecordingJobId}