Merge branch 'main' into pg-index-management-ui

This commit is contained in:
Diego Imbert
2026-06-03 09:23:31 +02:00
committed by GitHub
215 changed files with 13670 additions and 2964 deletions
+62
View File
@@ -1,5 +1,67 @@
# Changelog
## [1.714.1](https://github.com/windmill-labs/windmill/compare/v1.714.0...v1.714.1) (2026-06-02)
### Bug Fixes
* **backend:** route //native TypeScript previews to native workers (WIN-2007) ([#9407](https://github.com/windmill-labs/windmill/issues/9407)) ([73edebc](https://github.com/windmill-labs/windmill/commit/73edebc833a981488a8ea116f4f13c020a011a6f))
* **nsjail:** raise python download fd limit for --compile-bytecode (WIN-2009) ([#9414](https://github.com/windmill-labs/windmill/issues/9414)) ([9e6559a](https://github.com/windmill-labs/windmill/commit/9e6559a6f688cc8d982277b19920219ea6d0fd8e))
* **triggers:** prevent Zoom challenge handler from being used as a signing oracle ([#9413](https://github.com/windmill-labs/windmill/issues/9413)) ([ab2a15b](https://github.com/windmill-labs/windmill/commit/ab2a15b2a859096eabde718bf6e60289ae187118))
## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02)
### Features
* add global ai chat test tools ([#9391](https://github.com/windmill-labs/windmill/issues/9391)) ([5c20d6b](https://github.com/windmill-labs/windmill/commit/5c20d6b4f79f2ccc1987ce7fdaf74e6b8f697846))
* add workspace datatable tools to global AI chat mode ([#9395](https://github.com/windmill-labs/windmill/issues/9395)) ([943ef6e](https://github.com/windmill-labs/windmill/commit/943ef6eb2089f4b744cfa7945ce47f7f3b361ec7))
* **flow-ai:** constrain flow-group colors to the NoteColor palette ([#9343](https://github.com/windmill-labs/windmill/issues/9343)) ([e4213c1](https://github.com/windmill-labs/windmill/commit/e4213c1ab8c448f492f372580f5c9df37e33fffc))
* **frontend:** surface local drafts in drawer editors with an unsaved-changes banner ([#9335](https://github.com/windmill-labs/windmill/issues/9335)) ([075faab](https://github.com/windmill-labs/windmill/commit/075faabf3bba16a10a02ae3973008e5a13473085))
* handle CTRL_BREAK_EVENT for graceful shutdown on Windows ([#9400](https://github.com/windmill-labs/windmill/issues/9400)) ([2e14456](https://github.com/windmill-labs/windmill/commit/2e1445616a412c5112ad2247b4087c7ddc218845))
* refine ask-user-question chat display and keyboard nav ([#9392](https://github.com/windmill-labs/windmill/issues/9392)) ([1275487](https://github.com/windmill-labs/windmill/commit/1275487f028d4c74a9eeb18981ed05c225505be0))
* sessions page with isolated AI chat + flow editor ([#9034](https://github.com/windmill-labs/windmill/issues/9034)) ([eadeac2](https://github.com/windmill-labs/windmill/commit/eadeac248bd022c2796cfe638eb617c6143b8fc4))
### Bug Fixes
* **cli:** make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change ([#9402](https://github.com/windmill-labs/windmill/issues/9402)) ([e356bb1](https://github.com/windmill-labs/windmill/commit/e356bb1f5df92eca3fbb0ca2114b9f4c32d4c496))
* **cli:** stop git-sync promotion deploys from dropping triggers/schedules ([#9403](https://github.com/windmill-labs/windmill/issues/9403)) ([24e3ef2](https://github.com/windmill-labs/windmill/commit/24e3ef27be8498fb820c228a52febf6a0a91b487))
* **frontend:** align Monaco editor font size with text-xs ([#9161](https://github.com/windmill-labs/windmill/issues/9161)) ([de76668](https://github.com/windmill-labs/windmill/commit/de76668c10c04abe8771a8ca7bba7b2259819a1c))
* resolve username rename failing on apps with runnable deps ([#9401](https://github.com/windmill-labs/windmill/issues/9401)) ([e8ad53d](https://github.com/windmill-labs/windmill/commit/e8ad53dae92597f5a1a8b76f38a7d8c24f578a47))
### Performance Improvements
* **python:** add --compile-bytecode to uv pip install ([#9393](https://github.com/windmill-labs/windmill/issues/9393)) ([c19441b](https://github.com/windmill-labs/windmill/commit/c19441bc8cb2da064e4ad44d77dc04ab8bbb22ec))
## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01)
### Bug Fixes
* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441))
## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31)
### Features
* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee))
* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e))
### Bug Fixes
* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7))
* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4))
* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020))
* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd))
* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3))
* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb))
* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4))
* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946))
## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28)
+1 -1
View File
@@ -300,7 +300,7 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+1 -3
View File
@@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run global global-test1-script-create
bun run cli -- run cli bun-hello-script
@@ -89,8 +89,6 @@ Today:
- `opus`
- `4o`
- `gpt-5.5`
- `gemini-flash`
- `gemini-pro`
- `gemini-3-flash-preview`
- `gemini-3.1-pro-preview`
- `deepseek-v4-flash`
@@ -21,9 +21,9 @@ describe("proxy helpers", () => {
describe("resolveEvalModelProvider", () => {
it("infers googleai from Gemini model ids", () => {
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
model: "gemini-3-flash-preview",
});
});
@@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => {
});
it("preserves an explicit provider", () => {
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
expect(
resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"),
).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
model: "gemini-3.1-pro-preview",
});
});
});
+11
View File
@@ -8,6 +8,9 @@
args:
a: 4
b: 5
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -25,6 +28,9 @@
args:
a: 2
b: 3
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the flow takes `a` and `b` as inputs"
- "the main step is named `sum_numbers`"
@@ -42,6 +48,9 @@
args:
a: 7
b: 8
toolExpect:
requiredToolsUsed:
- test_run_flow
judgeChecklist:
- "the parent flow takes `a` and `b` as inputs"
- "the main step is named `call_add_numbers`"
@@ -426,6 +435,7 @@
- return_schedule_status
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -453,6 +463,7 @@
- webhook_response
toolExpect:
requiredToolsUsed:
- test_run_flow
- create_trigger
toolCallArgs:
- tool: create_trigger
+188
View File
@@ -430,3 +430,191 @@
judgeChecklist:
- asks which script to update when the user refers to "this script" without selected or active editor context
- does not guess a path or create a new script draft
- id: global-test15-human-postgres-resource
prompt: |-
I'm wiring the eval reporting database into this workspace.
Can you stage a Postgres connection for it in the shared evals/global folder?
Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`.
Keep the credentials safe.
This is just draft work for now.
runtime:
maxTurns: 10
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
- password
valueIncludes:
- "true"
- report
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- report
valueIncludes:
- postgres
- reports-db.internal
- "5432"
- evals_reporting
- report_reader
- "$var:"
valueExcludes:
- pg-redacted-reporting-password
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- pg-redacted-reporting-password
skipJudge: true
judgeChecklist:
- creates a Postgres resource draft for the eval reporting database
- creates a secret variable draft for the database password
- puts the drafts in sensible eval/global reporting-related paths
- uses the requested host, port, database, and user
- references the secret variable from the resource instead of embedding the password
- leaves the work as a draft only
- id: global-test16-human-visible-variable
prompt: |-
We keep reusing a 30 day trial cutoff in eval notification jobs.
Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls?
It is not a secret.
runtime:
maxTurns: 6
validate:
draftCountExactly: 1
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- trial
valueIncludes:
- "30"
- "false"
- trial
toolExpect:
requiredToolsUsed:
- write_variable
forbiddenToolsUsed:
- write_resource
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates exactly one non-secret variable draft for the trial cutoff
- stores the value 30
- chooses a sensible eval/global path related to trials or notifications
- includes a useful description of what the value controls
- does not create resources, schedules, triggers, or deployed workspace changes
- id: global-test17-human-schedule-existing-helper
prompt: |-
The workspace already has a report digest helper.
Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on?
I only want the schedule draft for review.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: schedule
pathIncludes:
- digest
valueIncludes:
- f/evals/global/send_report_digest
- UTC
- dry_run
- "true"
toolExpect:
requiredToolsUsed:
- list_workspace_items
- write_schedule
forbiddenToolsUsed:
- write_script
- write_flow
- write_resource
- write_variable
- write_trigger
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- finds the existing report digest helper rather than creating a new script or flow
- creates one schedule draft for that helper
- schedules it for weekdays around 08:30 UTC
- passes dry_run as true
- leaves only the schedule draft for review
- id: global-test18-human-slack-resource-with-secret
prompt: |-
I'm preparing Slack notifications for eval failures.
Can you stage a Slack connection in the shared evals/global folder?
The bot token is `xoxb-redacted-test-token`; keep it safe.
Don't deploy anything yet.
runtime:
maxTurns: 8
validate:
draftCountExactly: 2
requiredDrafts:
- type: variable
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
- token
valueIncludes:
- "true"
- type: resource
pathStartsWith: f/evals/global/
pathIncludes:
- evals
- global
- slack
valueIncludes:
- slack
- "$var:"
valueExcludes:
- xoxb-redacted-test-token
toolExpect:
requiredToolsUsed:
- write_variable
- search_resource_types
- write_resource
forbiddenToolsUsed:
- write_schedule
- write_trigger
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_variable
field: value
stringStartsWithAnyOf:
- xoxb-redacted-test-token
skipJudge: true
judgeChecklist:
- creates a secret variable draft for the Slack bot token placeholder
- creates a Slack resource draft that references the secret variable instead of embedding the token
- keeps both drafts under a sensible eval/global Slack-related path
- does not create schedules, triggers, or deployed workspace changes
+5
View File
@@ -5,6 +5,9 @@
Keep it simple and do not add external dependencies.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
judgeChecklist:
- uses the existing `name` input
- returns a plain greeting string
@@ -20,6 +23,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_schedule
toolCallArgs:
- tool: create_schedule
@@ -44,6 +48,7 @@
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
toolExpect:
requiredToolsUsed:
- test_run_script
- create_trigger
toolCallArgs:
- tool: create_trigger
+16 -1
View File
@@ -14,6 +14,21 @@ describe("loadCases", () => {
},
},
});
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads script and flow test tool expectations", async () => {
const scriptCases = await loadCases("script");
const flowCases = await loadCases("flow");
expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_script"],
});
expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({
requiredToolsUsed: ["test_run_flow"],
});
});
it("loads the workspace-flow preference benchmark case", async () => {
@@ -238,7 +253,7 @@ describe("loadCases", () => {
);
expect(caseEntry?.toolExpect).toEqual({
requiredToolsUsed: ["create_schedule"],
requiredToolsUsed: ["test_run_script", "create_schedule"],
toolCallArgs: [
{
tool: "create_schedule",
+2 -10
View File
@@ -18,14 +18,6 @@ describe("resolveEvalModel", () => {
});
it("supports Gemini aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "gemini").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-flash",
});
expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({
provider: "googleai",
model: "gemini-2.5-pro",
});
expect(
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
).toEqual({
@@ -52,8 +44,8 @@ describe("resolveEvalModel", () => {
});
it("rejects Gemini aliases for cli evals", () => {
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
"Model gemini-flash is not supported for cli mode",
expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow(
"Model gemini-3-flash-preview is not supported for cli mode",
);
});
});
-18
View File
@@ -96,24 +96,6 @@ export const EVAL_MODELS: EvalModelSpec[] = [
model: "gpt-5.5",
},
},
{
id: "gemini-flash",
label: "Gemini 2.5 Flash",
aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"],
frontend: {
provider: "googleai",
model: "gemini-2.5-flash",
},
},
{
id: "gemini-pro",
label: "Gemini 2.5 Pro",
aliases: ["gemini-pro", "gemini-2.5-pro"],
frontend: {
provider: "googleai",
model: "gemini-2.5-pro",
},
},
{
id: "gemini-3-flash-preview",
label: "Gemini 3 Flash Preview",
@@ -0,0 +1,23 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/send_report_digest",
"summary": "Build and send the eval report digest",
"description": "Returns a dry-run summary for eval report digest notifications.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"dry_run": {
"type": "boolean"
}
},
"required": ["dry_run"]
},
"content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n"
}
]
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT id AS \"id!\" FROM chain",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Text",
"Bool",
"TextArray",
"Bool"
]
},
"nullable": []
},
"hash": "7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50"
}
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "SELECT tag, script_lang AS \"script_lang: ScriptLang\" FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886"
}
+197 -207
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.712.0"
version = "1.714.1"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.712.0"
version = "1.714.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
08e3b9b818f9f1b439e9a6ba477fd5fdbcab944e
3742e0659c5e97aab03b9efeea14cd94a3ac658a
+13
View File
@@ -181,5 +181,18 @@
"auth_url": "https://account-d.docusign.com/oauth/auth",
"token_url": "https://account-d.docusign.com/oauth/token"
}
},
"salesforce": {
"auth_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
"scopes": [
"api",
"refresh_token",
"offline_access"
],
"sandbox": {
"auth_url": "https://test.salesforce.com/services/oauth2/authorize",
"token_url": "https://test.salesforce.com/services/oauth2/token"
}
}
}
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.712.0"
version = "1.714.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.712.0"
version = "1.714.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+10
View File
@@ -258,6 +258,15 @@ pub fn main() -> anyhow::Result<()> {
}
async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
// The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its
// compiled default. Allow overriding it via env so the prebuild cache step can
// be pointed at a private/staging hub (e.g. a local proxy for testing).
if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") {
if !hub_base_url.is_empty() {
tracing::info!("Overriding hub base url from env: {hub_base_url}");
windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url));
}
}
let file_path = file_path.unwrap_or("./hubPaths.json".to_string());
let mut file = File::open(&file_path)
.await
@@ -567,6 +576,7 @@ fn print_help() {
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start");
println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)");
println!();
println!("Notes:");
println!("- Advanced and less commonly used settings are managed via the database and are omitted here.");
+2
View File
@@ -451,6 +451,7 @@ def main():
preserve_on_behalf_of: None,
ws_error_handler_muted: None,
labels: None,
skip_draft_deletion: None,
})
.send()
.await
@@ -513,6 +514,7 @@ def main():
custom_path: None,
preserve_on_behalf_of: None,
labels: None,
skip_draft_deletion: None,
})
.send()
.await
+192
View File
@@ -0,0 +1,192 @@
-- Fixture for the single-job read authorization regression test
-- (see tests/jobs_read_auth.rs).
--
-- Users available from `base`:
-- test-user (admin, token SECRET_TOKEN)
-- test-user-2 (User, token SECRET_TOKEN_2) -- owner of the secret script
-- test-user-3 (User, token SECRET_TOKEN_3) -- the unprivileged "viewer"
--
-- test-user-3 is NOT a member of any folder/group granting access to
-- `u/test-user-2/...`, so under the same RLS as `jobs/list` they cannot see any
-- of these jobs unless they created them.
-- A tag-scoped token for test-user-2 (who can read both VICTIM (tag 'deno') and
-- the flow (tag 'flow')). The `if_jobs:filter_tags:deno` modifier restricts it to
-- the 'deno' tag, so it must NOT be able to mint a share token for the 'flow' job.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('SCOPED_DENO_TOKEN'::bytea), 'hex'), 'SCOPED_DEN', 'SCOPED_DENO_TOKEN',
'test2@windmill.dev', 'scoped deno token', false,
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
-- running-state to a non-reader.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'77777777-7777-7777-7777-777777777777', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/running_secret', 'deno', true
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('77777777-7777-7777-7777-777777777777', 'test-workspace', '2023-01-01 00:00:00', true, 'deno');
-- 1. VICTIM job: a completed run of test-user-2's private script, e.g. produced
-- by a public HTTP trigger. `created_by` is the route identity (test-user-2),
-- NOT the viewer; `permissioned_as`/`runnable_path` sit in test-user-2's
-- namespace; `visible_to_owner` is true. Its args + result carry secrets.
-- Pre-fix, test-user-3 could read all of these by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/secret_script', 'deno', true,
'{"secret": "LEAK_TEST_ARGS"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 1000,
'success'::job_status, '{"secret": "RESULT_SECRET"}'
);
INSERT INTO public.job_logs (job_id, workspace_id, logs) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'secret logs LEAK_TEST_LOGS');
-- 2. APP-style job: run by the viewer (test-user-3) on behalf of an app whose
-- policy executes as test-user-2. `created_by` is the launching viewer, but
-- `permissioned_as`/`runnable_path` are the app owner's and
-- `visible_to_owner` is false (apps hide their component runs from the runs
-- list). This is the case that must KEEP working after the fix: the viewer
-- polls their own component result by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 'test-user-3',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
'{"app_arg": "ok"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 1000,
'success'::job_status, '{"app_result": "visible_to_launcher"}'
);
-- 3. ANONYMOUS job: a public-trigger run whose creator is `anonymous`. Reading
-- it without authentication must keep working (unchanged behavior).
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 'anonymous',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/public_trigger', 'deno', true,
'{"public": "arg"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 1000,
'success'::job_status, '{"public": "result"}'
);
-- 4. FLOW + STEP: test-user-3 has *read* access to folder `shared` (extra_perms),
-- so they can see flow `f/shared/flow1` (run by test-user-2) even though they
-- did not launch it. The flow's STEP job runs the inner script
-- `u/test-user-2/inner_secret` (test-user-3 has NO direct ACL on it) and is
-- not in their list. Visibility must be INHERITED from the flow root: being
-- able to see the flow means being able to inspect its steps (the flow-run UI
-- fetches each step by id). This guards against the fix over-blocking.
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/test-user-2"}',
'{"u/test-user-3": false}', 'test-user-2');
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/flow1', 'flow', true
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 1000,
'success'::job_status, '{"flow": "done"}'
);
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job, args
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/inner_secret', 'deno', true,
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'dddddddd-dddd-dddd-dddd-dddddddddddd', '{"step_arg": "x"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 1000,
'success'::job_status, '{"step": "STEP_RESULT_INHERITED"}'
);
-- 5. DEEP NESTING / MIDDLE-LAYER VISIBILITY: top flow `f/secret/top` is NOT
-- visible to test-user-3; it has a sub-flow step `f/shared/mid` that IS visible
-- (folder `shared`); and that sub-flow has its own leaf step running
-- `u/test-user-2/deep_secret` (not visible). The leaf's `root_job` points at the
-- *outermost* top (not visible), so visibility must come from the *intermediate*
-- sub-flow the user can see — which requires walking the full parent chain, not
-- just [self, root].
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'secret', 'Secret Folder', '{"u/test-user-2"}', '{}', 'test-user-2');
-- top flow (not visible to test-user-3)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/secret/top', 'flow', true
);
-- intermediate sub-flow (visible via folder `shared`), child of top
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'99999999-9999-9999-9999-999999999999', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/mid', 'flow', true,
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ffffffff-ffff-ffff-ffff-ffffffffffff'
);
-- leaf step of the sub-flow; runnable not visible, root_job = outermost top (not visible)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'88888888-8888-8888-8888-888888888888', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/deep_secret', 'deno', true,
'99999999-9999-9999-9999-999999999999', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'99999999-9999-9999-9999-999999999999'
);
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 1000, 'success'::job_status,
'{"top": "TOP_SECRET_RESULT"}'),
('99999999-9999-9999-9999-999999999999', 'test-workspace', 1000, 'success'::job_status,
'{"mid": "MID_RESULT"}'),
('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status,
'{"deep": "DEEP_STEP_INHERITED"}');
+512
View File
@@ -0,0 +1,512 @@
//! Regression test for the single-job read authorization bypass.
//!
//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`,
//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a
//! job through the root DB handle, filtered only by job id + workspace. That is
//! required for the unauthenticated approval / public-trigger / anonymous-job
//! flows, but for a *logged-in* user it meant any workspace member — including a
//! plain viewer with no ACL on the runnable — could read another user's job
//! args/result/logs simply by obtaining the job UUID, even though the same job is
//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script
//! returns 404.
//!
//! The fix (`require_job_read_access`) gates the authenticated case: a caller may
//! read a job they created (covers app components / webhooks / their own runs)
//! or one visible to them under the same RLS as `jobs/list` (admins bypass);
//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only).
//!
//! This test pins down, against the `jobs_read_auth` fixture:
//! - a viewer is denied the victim job's full record / result / result_maybe /
//! args / logs / live update by UUID, and the secret never appears in the
//! body (the core fix; pre-fix these returned 200 with the secret),
//! - the job's owner and an admin can still read it (no over-blocking),
//! - the "app component" affordance survives: a viewer who *launched* a job
//! (created_by) running as someone else's identity can still read its result,
//! - unauthenticated behavior is unchanged: anonymous jobs readable, the
//! non-anonymous victim job rejected.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf.
const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
const ARGS_SECRET: &str = "LEAK_TEST_ARGS";
const LOGS_SECRET: &str = "LEAK_TEST_LOGS";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client().get(format!("{base}/{path}"));
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<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
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");
// The endpoints that return the victim job's sensitive data by UUID.
let endpoints = [
("get", format!("get/{VICTIM}")),
("completed/get", format!("completed/get/{VICTIM}")),
(
"completed/get_result",
format!("completed/get_result/{VICTIM}"),
),
(
"completed/get_result_maybe",
format!("completed/get_result_maybe/{VICTIM}"),
),
("get_args", format!("get_args/{VICTIM}")),
("get_logs", format!("get_logs/{VICTIM}")),
(
"get_completed_logs_tail",
format!("get_completed_logs_tail/{VICTIM}"),
),
("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")),
(
"completed/get_timing",
format!("completed/get_timing/{VICTIM}"),
),
("getupdate", format!("getupdate/{VICTIM}?only_result=true")),
];
// ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint
// and no secret ever appears in the body. Pre-fix these returned 200
// and leaked the secret.
for (name, path) in &endpoints {
let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on {name} (got {status}): {body}"
);
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
assert!(
!body.contains(secret),
"viewer response for {name} leaked `{secret}`: {body}"
);
}
}
// The 403 for an existing-but-forbidden job carries actionable guidance
// (request a share link), distinguishing it from a plain not-found.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
assert!(
body.to_lowercase().contains("share"),
"403 body should guide the user to request a share link: {body}"
);
// A genuinely non-existent job is a 404, not a 403 — existence is only disclosed
// for jobs that actually exist in the workspace.
let missing = "00000000-0000-4000-8000-000000000000";
let (status, _) = get(
&base,
&format!("completed/get_result/{missing}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"a non-existent job must be 404, not 403 (got {status})"
);
// ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must still read their own job result (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"owner result must contain the value: {body}"
);
// ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN"),
)
.await;
assert!(
status.is_success(),
"admin must read any job (got {status}): {body}"
);
assert!(body.contains(RESULT_SECRET), "admin result body: {body}");
// ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that
// runs as another identity (permissioned_as = test-user-2,
// visible_to_owner = false) can still read its result. This is the app
// component-polling path; the fix must not break it.
let (status, body) = get(
&base,
&format!("completed/get_result/{APP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"launcher must read a job they created even without ACL on the runnable (got {status}): {body}"
);
assert!(
body.contains("visible_to_launcher"),
"launcher should get the result they polled: {body}"
);
// ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node
// result) and get_otel_traces (job telemetry). The viewer must be denied
// the victim by UUID. The auth gate runs before result/trace resolution,
// so 404 here is the gate, not incidental resolution failure.
let (status, body) = get(
&authed_base,
&format!("result_by_id/{VICTIM}/somenode"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on result_by_id (got {status}): {body}"
);
assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}");
let (status, body) = get(
&authed_base,
&format!("get_otel_traces/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on get_otel_traces (got {status}): {body}"
);
// ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow
// `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no
// ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must
// still be able to (a) read the flow they can see, and (b) inspect its
// step result — visibility is inherited from the flow root. A naive
// "same as list" gate would 404 the step and break the flow-run UI.
let (status, body) = get(
&base,
&format!("completed/get_result/{FLOW_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer with folder ACL must read the flow they can see (got {status}): {body}"
);
let (status, body) = get(
&base,
&format!("completed/get_result/{STEP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}"
);
assert!(
body.contains("STEP_RESULT_INHERITED"),
"step result should be returned via flow-root inheritance: {body}"
);
// ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the
// top flow (NOT visible to test-user-3), but an intermediate sub-flow
// (f/shared/mid) IS visible. Reading the leaf must succeed via that middle
// ancestor — i.e. the full parent chain is walked, not just [self, root].
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}"
);
assert!(
body.contains("DEEP_STEP_INHERITED"),
"deep leaf result should be returned via mid-ancestor visibility: {body}"
);
// ...but the top flow itself, in a folder the viewer cannot read, stays denied.
let (status, body) = get(
&base,
&format!("completed/get_result/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"top flow in an unreadable folder must stay denied (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
// without a token (public trigger / public app result polling).
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
assert!(
status.is_success(),
"anonymous job must remain readable unauthenticated (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected
// for an unauthenticated caller (400, the pre-existing guard).
let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}"
);
assert!(
!body.contains(RESULT_SECRET),
"unauth body must not leak: {body}"
);
// ---- SHARE READ LINK (view_token) ----
// The owner (test-user-2) mints a share token for the victim job.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must be able to mint a share token (got {status}): {mint_body}"
);
let token = mint_body.trim().trim_matches('"').to_string();
assert!(
token.starts_with(VICTIM),
"token must encode the job id: {token}"
);
// The viewer (no ACL) can now read the victim job via the share link.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must grant the viewer read of the shared job (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"shared job result must be returned with a valid view_token: {body}"
);
// ...and its args/logs too (whole detail page).
let (status, _) = get(
&base,
&format!("get_args/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must also grant args (got {status})"
);
// The token is scoped: it does NOT authorize an unrelated job.
let (status, _) = get(
&base,
&format!("completed/get_result/{ANON_JOB}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a victim-scoped token must not authorize a different job (got {status})"
);
// A garbage token is rejected (falls through to the normal 404).
let (status, _) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"an invalid view_token must not grant access (got {status})"
);
// A share token authorizes the shared job's whole flow subtree: the owner mints
// for the top secret flow, and the viewer can then read its deep leaf.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints token for top flow (got {status}): {mint_body}"
);
let top_token = mint_body.trim().trim_matches('"').to_string();
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"a flow's share token must authorize its deep descendants (got {status}): {body}"
);
// A viewer who cannot read a job cannot mint a share token for it.
let (status, _) = get(
&authed_base,
&format!("job_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 share token (got {status})"
);
// ---- 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
// tag scope: allowed for the deno job, denied for the flow job.
let (status, body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}"
);
let (status, _) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})"
);
// ---- USE side: a tag-scoped token must not use someone else's valid view_token
// to read an out-of-scope job, even via handlers that don't tag-filter their
// data query (result_by_id, get_otel_traces, get_flow_debug_info). ----
// An unscoped owner mints a valid token for the flow (tag 'flow').
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints flow token (got {status}): {mint_body}"
);
let flow_token = mint_body.trim().trim_matches('"').to_string();
// The deno-scoped token presents that valid flow token to the non-tag-filtered
// endpoints — must still be denied (flow tag is out of its scope).
for path in [
format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"),
format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"),
] {
let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})"
);
}
// ...but the deno-scoped token CAN use an in-scope (deno) view_token.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}"
);
// ---- get_result_maybe?get_started=true must authorize before disclosing the
// running-state of a queued (not-yet-completed) private job. ----
// Viewer (no ACL) must be denied rather than told the job is started.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must be denied the running-state of a private queued job (got {status}): {body}"
);
assert!(
!body.contains("\"started\""),
"denied response must not disclose started-state: {body}"
);
// The owner still gets the in-progress response.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success() && body.contains("\"started\":true"),
"owner must see the running job as started (got {status}): {body}"
);
Ok(())
}
+122
View File
@@ -0,0 +1,122 @@
/*
* Regression tests for WIN-2007.
*
* Previewing a TypeScript script carrying the `//native` annotation used to be
* pushed with `language = bun` (what the editor sends), so the job was tagged
* `bun` and routed to a regular bun worker. A native-mode worker neither matches
* the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native`
* script on a native-only worker setup failed even though the *deployed* version
* of the same script runs fine (as `bunnative` / tag `nativets`).
*
* `push` now reconciles the preview language with the `//native` annotation,
* mirroring the deploy-time logic in `worker_lockfiles`. These tests assert the
* queued job ends up with the right `script_lang` and `tag` for every combination
* of declared language and annotation. No worker is spawned — we only inspect the
* row `push` writes.
*/
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_queue::PushIsolationLevel;
async fn push_preview_and_get_row(
db: &Pool<Postgres>,
content: &str,
language: ScriptLang,
) -> (String, Option<ScriptLang>) {
let hm_args = std::collections::HashMap::new();
let job = JobPayload::Code(RawCode {
hash: None,
content: content.to_string(),
path: None,
language,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (uuid, tx) = windmill_queue::push(
db,
tx,
"test-workspace",
job,
windmill_queue::PushArgs::from(&hm_args),
/* user */ "test-user",
/* email */ "test@windmill.dev",
/* permissioned_as */ "u/test-user".to_string(),
/* token_prefix */ None,
/* scheduled_for */ None,
/* schedule_path */ None,
/* parent_job */ None,
/* root_job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* is_flow_step */ false,
/* same_worker */ false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await
.expect("push must succeed");
tx.commit().await.unwrap();
let row = sqlx::query!(
r#"SELECT tag, script_lang AS "script_lang: ScriptLang" FROM v2_job WHERE id = $1"#,
uuid
)
.fetch_one(db)
.await
.unwrap();
(row.tag, row.script_lang)
}
const NATIVE_CONTENT: &str = r#"//native
export function main(x: number) {
return x;
}
"#;
const PLAIN_CONTENT: &str = r#"export function main(x: number) {
return x;
}
"#;
/// The reported case: editor sends `bun`, content has `//native`. The preview
/// must be promoted to `bunnative` so it tags `nativets` and a native worker
/// (which rejects non-native `script_lang`) can run it.
#[sqlx::test(fixtures("base"))]
async fn test_bun_with_native_annotation_becomes_nativets(db: Pool<Postgres>) {
let (tag, lang) = push_preview_and_get_row(&db, NATIVE_CONTENT, ScriptLang::Bun).await;
assert_eq!(lang, Some(ScriptLang::Bunnative));
assert_eq!(tag, "nativets");
}
/// Guard: a plain bun preview (no `//native`) must stay `bun` / tag `bun`, so
/// the promotion above doesn't broadly retag normal previews.
#[sqlx::test(fixtures("base"))]
async fn test_bun_without_native_annotation_stays_bun(db: Pool<Postgres>) {
let (tag, lang) = push_preview_and_get_row(&db, PLAIN_CONTENT, ScriptLang::Bun).await;
assert_eq!(lang, Some(ScriptLang::Bun));
assert_eq!(tag, "bun");
}
+1 -1
View File
@@ -20,7 +20,7 @@ where
lazy_static::lazy_static! {
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
.ok()
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
+477
View File
@@ -235,6 +235,202 @@ where
Ok(())
}
/// Returns the caller's "real" scope restrictions: every scope other than
/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and
/// has the full privileges of its user; `Some` means it is restricted to the
/// returned scopes. An empty or filter-tags-only scope list is treated as
/// unscoped, mirroring `check_scopes`/`check_route_access`.
fn scope_restrictions(scopes: Option<&[String]>) -> Option<Vec<&String>> {
let restrictions: Vec<&String> = scopes?
.iter()
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
.collect();
(!restrictions.is_empty()).then_some(restrictions)
}
/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes
/// a credential on behalf of `authed`: the resulting credential must never be
/// more privileged than the caller's own token.
///
/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow).
/// - A scope-restricted caller may only grant scopes that are a subset of its
/// own, and may never produce an unscoped credential.
///
/// Without this, a `users:write` token could create or rescope a token to be
/// unscoped, and a `users:read` token could refresh into an unscoped session —
/// escaping its own restrictions.
pub fn ensure_scopes_within_caller(
authed: &ApiAuthed,
requested_scopes: Option<&[String]>,
) -> error::Result<()> {
if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) {
let Some(requested_restrictions) = scope_restrictions(requested_scopes) else {
return Err(Error::PermissionDenied(
"A scope-restricted token cannot create or update a token with broader (unscoped) \
privileges"
.to_string(),
));
};
// MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a
// custom format that ScopeDefinition::from_scope_string parses
// permissively but the MCP runtime interprets via its own parser
// (parse_mcp_scopes). The two views disagree — e.g. the generic parser
// accepts `mcp:scripts` as an unrestricted-resource scope, while the
// MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*`
// as granting all scripts. So generic containment would silently allow
// `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token
// issuance goes through the OAuth gateway (mcp/oauth_server.rs), not
// these user-token endpoints, so require byte-identical match for MCP
// scopes here rather than trying to mirror MCP semantics in two places.
// Unparseable non-MCP caller scopes are intentionally dropped
// (fail-closed): a caller scope that fails to parse can only narrow
// the set of requested scopes that get covered, never widen it.
// Unparseable requested scopes surface as `BadRequest`, which is what
// we want — the client is sending garbage.
let parsed_caller: Vec<ScopeDefinition> = caller_restrictions
.iter()
.filter(|s| !s.starts_with("mcp:"))
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
.collect();
let caller_mcp: std::collections::HashSet<&str> = caller_restrictions
.iter()
.filter(|s| s.starts_with("mcp:"))
.map(|s| s.as_str())
.collect();
for requested in requested_restrictions {
if requested.starts_with("mcp:") {
if !caller_mcp.contains(requested.as_str()) {
return Err(Error::PermissionDenied(format!(
"A scope-restricted token cannot grant MCP scope '{requested}' unless the \
caller holds the same scope verbatim"
)));
}
continue;
}
let requested_scope = ScopeDefinition::from_scope_string(requested)?;
let covered = parsed_caller
.iter()
.any(|caller_scope| scope_contains(caller_scope, &requested_scope));
if !covered {
return Err(Error::PermissionDenied(format!(
"A scope-restricted token cannot grant scope '{requested}' which exceeds its \
own scopes"
)));
}
}
}
// `if_jobs:filter_tags:` fences which job tags a token can run on (enforced
// at job operations as `v2_job.tag = ANY(...)`), and is checked independently
// of domain/action/resource subset. A caller restricted by filter_tags must
// not be able to mint or rescope a credential that drops or widens the fence
// — even if the caller has no other scope restrictions (filter_tags-only
// tokens otherwise look "unscoped" to `scope_restrictions`).
if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) {
let Some(requested_tags) = first_filter_tags(requested_scopes) else {
return Err(Error::PermissionDenied(
"A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \
drops the tag restriction"
.to_string(),
));
};
let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect();
for tag in &requested_tags {
if !caller_set.contains(tag) {
return Err(Error::PermissionDenied(format!(
"A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \
not within its own filter_tags"
)));
}
}
}
Ok(())
}
/// Tags from the first `if_jobs:filter_tags:<a,b,...>` scope, matching the
/// semantics of [`get_scope_tags`] (which is what the job runtime consults).
/// Returns `None` if no such scope is present.
fn first_filter_tags(scopes: Option<&[String]>) -> Option<Vec<&str>> {
scopes?.iter().find_map(|s| {
s.strip_prefix("if_jobs:filter_tags:")
.map(|tags| tags.split(',').collect())
})
}
/// Whether `caller` grants at least everything `requested` grants (directional
/// containment).
///
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
/// "does this scope grant access to a required action" using OR semantics over
/// resources (any overlap counts, and a `*` on either side matches), which is
/// correct for access checks but unsafe for subset checks — it would let a
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
/// EVERY requested resource is covered by SOME caller resource.
fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
if caller.domain != requested.domain {
return false;
}
// write subsumes read; otherwise the action must match exactly.
match (caller.action.as_str(), requested.action.as_str()) {
(c, r) if c == r || (c == "write" && r == "read") => {}
_ => return false,
}
if caller.domain == "jobs" && caller.action == "run" {
match (&caller.kind, &requested.kind) {
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
return false
}
// Caller pinned to a kind, but the request covers any kind.
(Some(_), None) => return false,
_ => {}
}
}
match (&caller.resource, &requested.resource) {
// Caller is unrestricted on resources: covers everything.
(None, _) => true,
// Caller is resource-restricted but the request is not: broader.
(Some(_), None) => false,
(Some(caller_resources), Some(requested_resources)) => {
resource_set_contains(caller_resources, requested_resources)
}
}
}
/// Every resource in `requested` must be covered by some resource in `caller`.
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
if caller.iter().any(|r| r == "*") {
return true;
}
requested
.iter()
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
}
/// Directional: does the single caller resource pattern cover `requested`?
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
/// may itself be a subtree wildcard, in which case the whole requested subtree
/// must fall within the caller's subtree.
fn resource_covers(caller: &str, requested: &str) -> bool {
if caller == requested {
return true;
}
let Some(prefix) = caller.strip_suffix("/*") else {
// An exact caller resource only covers itself (handled above).
return false;
};
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
requested_base == prefix
|| (requested_base.starts_with(prefix)
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
}
/// Returns a predicate that checks whether `path` is within the token's
/// scope for `{domain}:{action}:{path}`. For tokens without scope
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
@@ -574,6 +770,13 @@ impl NewToken {
}
}
/// Low-level token mint shared by trusted callers (the user-facing
/// `tokens/create` handler and internal mints such as native-trigger webhook
/// tokens). It does NOT enforce that `token_config.scopes` is within the
/// caller's own scopes — callers exposed to untrusted input must call
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
/// skip it, since their scopes derive from the action being authorized, not the
/// caller's token).
pub async fn create_token_internal(
tx: &mut sqlx::PgConnection,
db: &DB,
@@ -908,4 +1111,278 @@ mod tests {
assert!(allowed("u/alice/foo"));
assert!(!allowed("u/alice/bar"));
}
fn opt_scopes(scopes: Option<Vec<&str>>) -> Option<Vec<String>> {
scopes.map(|v| v.into_iter().map(String::from).collect())
}
// Regression tests for WIN-1999: scoped user tokens must not be able to
// mint or rescope credentials with broader privileges than themselves.
#[test]
fn unscoped_caller_can_grant_anything() {
let authed = authed_with_scopes(None);
assert!(ensure_scopes_within_caller(&authed, None).is_ok());
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_ok());
}
#[test]
fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() {
// The domain/action/resource subset check treats filter-tags-only as
// unrestricted, mirroring check_scopes/check_route_access. The tag
// dimension is checked separately (see filter_tags_dimension_is_monotonic).
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref()
)
.is_ok());
}
#[test]
fn filter_tags_dimension_is_monotonic() {
// Caller restricted to tag fence "a" cannot drop the fence …
let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"]));
assert!(ensure_scopes_within_caller(&single, None).is_err());
assert!(
ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref())
.is_err(),
"minting a token without filter_tags must be rejected"
);
// … cannot widen to a tag it lacks …
assert!(ensure_scopes_within_caller(
&single,
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
)
.is_err());
// … and cannot mint a token fenced on a disjoint tag.
assert!(ensure_scopes_within_caller(
&single,
opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref()
)
.is_err());
// Narrowing or matching the tag fence is allowed.
let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"]));
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref()
)
.is_ok());
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
)
.is_ok());
// A caller with a real scope plus a tag fence cannot drop just the fence.
let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"]));
assert!(ensure_scopes_within_caller(
&mixed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_err());
assert!(ensure_scopes_within_caller(
&mixed,
opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref()
)
.is_ok());
// An unrestricted caller may grant filter_tags freely.
let unscoped = authed_with_scopes(None);
assert!(ensure_scopes_within_caller(
&unscoped,
opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref()
)
.is_ok());
}
#[test]
fn scoped_caller_cannot_mint_unscoped_token() {
// Primitive 2 in the report: a users:write token minting an unscoped token.
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(&authed, None).is_err());
// Empty scope list is effectively unscoped and must also be rejected.
assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err());
// A scope list of only tag filters is effectively unscoped too.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_cannot_remove_its_own_scopes() {
// Primitive 3 in the report: a users:write token setting its scopes to null.
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(&authed, None).is_err());
}
#[test]
fn scoped_caller_cannot_grant_scope_it_lacks() {
let authed = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_can_grant_subset_of_own_scopes() {
let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"]));
// Equal scope is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
)
.is_ok());
// write implies read, so a narrower read scope is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:read"])).as_deref()
)
.is_ok());
// Tag filters narrow further and are always permitted.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref()
)
.is_ok());
}
#[test]
fn scoped_caller_cannot_broaden_resource_scope() {
let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"]));
// Narrower resource within the subtree is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref()
)
.is_ok());
// A nested subtree within the caller's subtree is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref()
)
.is_ok());
// The subtree root itself is allowed.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref()
)
.is_ok());
// A path outside the subtree is rejected.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref()
)
.is_err());
// read caller cannot grant write.
assert!(ensure_scopes_within_caller(
&authed,
opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref()
)
.is_err());
}
#[test]
fn mcp_scopes_require_byte_identical_match() {
// Regression for the access-grant-OR vs runtime-MCP-parser confusion:
// ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope
// and `mcp:scripts:*` as a strictly narrower one, so generic containment
// would silently allow widening. The MCP runtime however ignores
// `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts.
// Legitimate MCP token issuance is the OAuth gateway, not these
// user-token endpoints, so MCP scopes must match the caller verbatim.
// The bypass the reviewer flagged: malformed `mcp:scripts` would widen
// into the real `mcp:scripts:*` under generic containment.
let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"]));
assert!(ensure_scopes_within_caller(
&bypass,
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
)
.is_err());
// A caller without any MCP scope cannot grant one (widening on the MCP
// dimension), even if the rest of the requested scopes are within reach.
let no_mcp = authed_with_scopes(Some(vec!["users:write"]));
assert!(ensure_scopes_within_caller(
&no_mcp,
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
)
.is_err());
// Byte-identical MCP scope passes; an additional non-matching MCP scope
// alongside it does not.
let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"]));
assert!(ensure_scopes_within_caller(
&mcp_caller,
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
)
.is_ok());
assert!(ensure_scopes_within_caller(
&mcp_caller,
opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref()
)
.is_err());
// Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`)
// is rejected by the byte-identical rule. This is intentional — these
// endpoints are not the legitimate path for narrowing MCP tokens.
let mcp_all = authed_with_scopes(Some(vec!["mcp:all"]));
assert!(ensure_scopes_within_caller(
&mcp_all,
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
)
.is_err());
}
#[test]
fn scoped_caller_cannot_escalate_to_wildcard_or_superset() {
// Regression for the access-grant-OR vs subset-containment confusion:
// ScopeDefinition::includes would (incorrectly) allow all of these.
let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
// Minting `*` from a single-path scope must be rejected.
assert!(ensure_scopes_within_caller(
&star,
opt_scopes(Some(vec!["scripts:read:*"])).as_deref()
)
.is_err());
// Minting a broader subtree must be rejected.
assert!(ensure_scopes_within_caller(
&star,
opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref()
)
.is_err());
// A comma-separated list that adds an uncovered resource must be rejected,
// even though one element overlaps the caller's scope.
let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
assert!(ensure_scopes_within_caller(
&list,
opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref()
)
.is_err());
// A subset of a multi-resource caller scope is allowed.
let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"]));
assert!(ensure_scopes_within_caller(
&multi,
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
)
.is_ok());
// A wildcard caller covers any subset, but not `*`-less escalation rules apply
// only when the caller itself lacks `*`.
let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"]));
assert!(ensure_scopes_within_caller(
&wildcard,
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
)
.is_ok());
}
}
+23 -14
View File
@@ -558,13 +558,17 @@ async fn create_flow(
w_id
).execute(&mut *tx).await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
nf.path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !nf.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
nf.path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -1157,13 +1161,17 @@ async fn update_flow(
})?;
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
flow_path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !nf.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
flow_path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -2031,6 +2039,7 @@ mod tests {
})),
preprocessor_module: None,
same_worker: false,
preserve_step_tags: false,
skip_expr: None,
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -383,6 +383,8 @@ async fn remove_granular_acl(
// workspace export.
let table = if kind == "raw_app" { "app" } else { kind };
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
// LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can
// return >1 row, which would break the scalar subquery in RETURNING.
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
"WITH old AS (
SELECT extra_perms->$1 as old_write FROM {table}
@@ -390,7 +392,7 @@ async fn remove_granular_acl(
)
UPDATE {table} SET extra_perms = extra_perms - $1
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
RETURNING (SELECT old_write FROM old)::bool"
RETURNING (SELECT old_write FROM old LIMIT 1)::bool"
))
.bind(&owner)
.bind(path)
+16 -8
View File
@@ -737,6 +737,9 @@ async fn is_noop_deploy_against_parent(
// caller-intent flag (auto-resolve parent), not script state
auto_parent: _,
labels,
// caller-intent flag (preserve user drafts on CLI/git-sync deploys);
// transient, never persisted, does not change what the script *is*
skip_draft_deletion: _,
} = ns;
if path != &parent.path {
@@ -925,6 +928,9 @@ async fn create_script_internal<'c>(
}
}
let script_path = ns.path.clone();
// Caller-intent: CLI / git-sync deploys ask us to preserve any existing
// user draft at this path instead of wiping it as part of the deploy.
let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false);
let hash = ScriptHash(hash_script(&ns));
let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await;
@@ -1357,13 +1363,15 @@ async fn create_script_internal<'c>(
let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone());
if let Some(ref p_path) = p_path_opt {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
p_path,
&w_id
)
.execute(&mut *tx)
.await?;
if !skip_draft_deletion {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
p_path,
&w_id
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
@@ -1442,7 +1450,7 @@ async fn create_script_internal<'c>(
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
}
}
} else {
} else if !skip_draft_deletion {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
ns.path,
+22 -5
View File
@@ -1964,7 +1964,8 @@ async fn login(
windmill_common::login_rate_limit::record_login_failure(&email);
Err(Error::BadRequest("Invalid login".to_string()))
} else {
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
let token =
create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?;
let audit_author = AuditAuthor {
email: email.clone(),
@@ -2036,7 +2037,15 @@ async fn refresh_token(
.await?
.unwrap_or(false);
let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
let new_token = create_session_token(
&authed.email,
super_admin,
authed.scopes.as_deref(),
authed.read_only,
&mut tx,
cookies,
)
.await?;
audit_log(
&mut *tx,
@@ -2066,6 +2075,8 @@ lazy_static::lazy_static! {
pub async fn create_session_token<'c>(
email: &str,
super_admin: bool,
scopes: Option<&[String]>,
read_only: bool,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
cookies: Cookies,
) -> Result<String> {
@@ -2108,15 +2119,17 @@ pub async fn create_session_token<'c>(
sqlx::query!(
"INSERT INTO token
(token_hash, token_prefix, token, email, label, expiration, super_admin)
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)",
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
t_hash,
t_prefix,
plaintext as Option<&str>,
email,
"session",
&MAX_SESSION_VALIDITY_SECONDS.to_string(),
super_admin
super_admin,
scopes,
read_only,
)
.execute(&mut **tx)
.await?;
@@ -2146,6 +2159,8 @@ async fn create_token(
) -> Result<(StatusCode, String)> {
check_token_create_rate_limit(&authed.username)?;
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
@@ -2353,6 +2368,8 @@ async fn update_token_scopes(
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenScopesRequest>,
) -> Result<String> {
windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?;
let mut tx = db.begin().await?;
let updated: Option<String> = sqlx::query_scalar!(
+49 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.712.0
version: 1.714.1
title: Windmill API
contact:
@@ -9318,6 +9318,33 @@ paths:
application/json:
schema: {}
/w/{workspace}/jobs/job_view_token/{id}:
get:
summary: mint a read-only share token for a job
description: >
Returns a stateless `{job_id}.{hmac}` token that grants an authenticated
workspace member 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: getJobViewToken
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the share read token
content:
text/plain:
schema:
type: string
/w/{workspace}/flows/list_paths:
get:
summary: list all flow paths
@@ -9751,6 +9778,9 @@ paths:
type: boolean
deployment_message:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
responses:
"201":
description: flow created
@@ -9792,6 +9822,9 @@ paths:
properties:
deployment_message:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
responses:
"200":
@@ -10290,6 +10323,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
required:
- path
- value
@@ -10342,6 +10378,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
required:
- path
- value
@@ -10660,6 +10699,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
responses:
"200":
description: app updated
@@ -10706,6 +10748,9 @@ paths:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
js:
type: string
css:
@@ -21883,6 +21928,9 @@ components:
type: array
items:
type: string
skip_draft_deletion:
type: boolean
description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
required:
- path
+16
View File
@@ -305,6 +305,22 @@ async fn get_token_using_oauth(
resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?;
resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?;
resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?;
// Validate the resolved token_url against SSRF rules before issuing the request,
// mirroring the protection applied to base_url in `get_base_url` (same
// ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could
// point token_url at an internal/metadata address.
if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS {
use windmill_common::ssrf::SsrfValidationError;
windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url)
.await
.map_err(|e| match e {
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
"{e}. If you need to use private/internal AI endpoints, \
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
)),
e => Error::from(e),
})?;
}
let mut params = HashMap::new();
params.insert("grant_type", "client_credentials");
params.insert("scope", "https://cognitiveservices.azure.com/.default");
+32 -14
View File
@@ -306,6 +306,11 @@ pub struct CreateApp {
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this app must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
#[derive(Serialize, Deserialize)]
@@ -319,6 +324,11 @@ pub struct EditApp {
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this app must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
#[derive(Serialize, FromRow)]
@@ -1338,13 +1348,17 @@ async fn create_app_internal<'a>(
));
}
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !app.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
&w_id
)
.execute(&mut *tx)
.await?;
}
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)
@@ -1943,13 +1957,17 @@ async fn update_app_internal<'a>(
)));
}
};
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy.
if !ns.skip_draft_deletion.unwrap_or(false) {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
+632 -37
View File
@@ -17,6 +17,7 @@ use itertools::Itertools;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
@@ -344,6 +345,10 @@ pub fn workspaced_service() -> Router {
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
)
.route(
"/job_view_token/{id}",
get(get_job_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))
@@ -426,12 +431,27 @@ struct JsonPath {
pub approver: Option<String>,
}
async fn get_result_by_id(
OptViewToken(view_token): OptViewToken,
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
// Reading a node's result requires being able to read the flow itself (the node
// belongs to it). Gate on the flow's visibility (created_by / RLS / root
// inheritance) before resolving via the root DB.
require_job_update_read_access(
&db,
&user_db,
&authed,
&w_id,
&flow_id,
view_token.as_deref(),
)
.await?;
let res =
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
.await?;
@@ -441,6 +461,25 @@ async fn get_result_by_id(
Ok(Json(res))
}
/// Mint a stateless "share read link" token for a job. Only a caller who can already
/// read the job (creator / RLS / flow ancestor / admin) may mint it. The returned
/// `{job_id}.{hmac}` is passed back as the `view_token` query param on the run page's
/// reads, granting an authenticated member read of this job and its flow subtree.
async fn get_job_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
// No `view_token` here: minting requires the caller's own read access, so a share
// link cannot be used to mint further links. `require_job_read_access` also
// 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?;
Ok(format!("{id}.{hmac}"))
}
async fn get_root_job(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
@@ -690,9 +729,11 @@ async fn get_scheduled_for(
}
async fn get_flow_job_debug_info(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
tokened_o: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job = GetQuery::new()
@@ -700,6 +741,18 @@ async fn get_flow_job_debug_info(
.fetch_queued((&db).into(), &id, &w_id)
.await?;
if let Some(job) = job {
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?;
}
let is_flow = job.is_flow();
if job.is_flow_step || !is_flow {
return Err(error::Error::BadRequest(
@@ -857,10 +910,313 @@ struct GetJobQuery {
pub approval_token: Option<String>,
}
/// Authorize an *authenticated* caller to read a single job's data
/// (full job / args / result / logs / live updates).
///
/// Single-job read endpoints query through the root `DB` (RLS-bypassing), filtered
/// only by job id + workspace (+ token scope tags). That is required for the
/// unauthenticated approval / public-trigger / anonymous-job flows, but for a
/// logged-in user it meant any workspace member — e.g. a viewer with no ACL on the
/// runnable — could read another user's job args/result/logs simply by obtaining the
/// 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;
/// 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
/// than the caller, so they would otherwise fail the RLS probe; or
/// - the job is visible to the caller under the same RLS as `jobs/list`, probed on
/// `v2_job` via `user_db` (admins BYPASSRLS).
///
/// Optional share-read-link token (validated by [`validate_view_token`]). Read from
/// the `view_token` query parameter — needed for `EventSource`/SSE and direct links,
/// which can't set headers — falling back to the `X-View-Token` header, which lets the
/// frontend attach it to every generated-client request via a single interceptor
/// instead of threading it through each call. Read independently of each handler's own
/// `Query<T>` extractor (axum allows only one typed `Query`).
pub struct OptViewToken(pub Option<String>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OptViewToken {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let from_query = parts.uri.query().and_then(|q| {
serde_urlencoded::from_str::<Vec<(String, String)>>(q)
.ok()
.and_then(|pairs| {
pairs
.into_iter()
.find(|(k, _)| k == "view_token")
.map(|(_, v)| v)
})
});
let token = from_query.or_else(|| {
parts
.headers
.get("x-view-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
Ok(OptViewToken(token))
}
}
/// Otherwise returns 404 — matching `scripts/get` and avoiding existence disclosure.
async fn require_job_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
// Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a
// scoped token: it must never read a job outside its allowed tags, regardless of
// how authorization is otherwise satisfied (created_by / view token / RLS). Most
// read handlers also tag-filter their data query, but some (result_by_id,
// get_flow_job_debug_info, get_otel_traces) do not, so enforce it here — before
// the grants below — so a share token can't be used to escape the tag scope.
// `get_scope_tags` is `None` for unscoped callers (the common case), so this adds
// no query for normal sessions/tokens.
if let Some(tags) = get_scope_tags(authed) {
let in_scope = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
job_id,
w_id,
&tags.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
)
.fetch_one(db)
.await?
== Some(true);
if !in_scope {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
}
// Fast path: you can always read a job you launched. This is also load-bearing
// for apps — a component job runs as the app policy's `permissioned_as`, but its
// `created_by` is the launching viewer, so the RLS probe below would hide it.
if created_by == authed.username
|| authed
.username_override
.as_deref()
.is_some_and(|u| u == created_by)
{
return Ok(());
}
// 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.
if let Some(token) = view_token {
if validate_view_token(db, w_id, job_id, token).await? {
return Ok(());
}
}
// The probe below (chain walk + an RLS-scoped transaction) is comparatively
// expensive and the same (caller, job) is hit repeatedly — e.g. `getupdate`
// polling of a run you can see but did not launch, or an admin watching many
// runs. Cache the boolean outcome. All job-side inputs to the decision
// (created_by, runnable_path, permissioned_as, visible_to_owner, flow lineage)
// are immutable after creation, and every mutable caller-side input
// (is_admin / username / username_override / groups / folders) is folded into
// the key — so a permission change yields a new key rather than a stale hit, and
// no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE).
let cache_key = job_read_access_cache_key(authed, w_id, job_id);
let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) {
visible
} else {
// Visibility is inherited along the flow hierarchy: if you can read ANY flow
// that (transitively) contains this job, you can read the job. A step runs as
// its flow's `permissioned_as` but its `runnable_path` is the inner runnable's
// — which the caller may have no direct ACL on — and the flow-run UI fetches
// each step by id, so gating purely on the step's own RLS visibility would
// break inspecting a flow you can see but did not launch. We therefore probe
// RLS visibility of the job OR any of its `parent_job` ancestors (admins
// BYPASSRLS) — the same visibility as `jobs/list`.
let chain_ids = job_ancestor_chain_ids(db, w_id, job_id).await?;
let mut tx = user_db.clone().begin(authed).await?;
let visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&chain_ids[..],
w_id,
)
.fetch_one(&mut *tx)
.await?
== Some(true);
tx.commit().await?;
JOB_READ_ACCESS_CACHE.insert(cache_key, visible);
visible
};
if visible {
return Ok(());
}
// Denied. Distinguish "the run exists but you lack access" (actionable: ask a
// colleague for a share link) from "no such run", so the UI can guide the user.
// Only authenticated members reach this point and job UUIDs are non-enumerable,
// so disclosing mere existence to a member is an acceptable trade-off for the UX.
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&[*job_id][..],
w_id,
)
.fetch_one(db)
.await?
== Some(true);
if exists {
Err(Error::PermissionDenied(format!(
"You do not have access to run {job_id}. Ask a user who can see it to open the run and \
share a read-only link with you (the \"Share\" button on the run page)."
)))
} else {
Err(Error::NotFound(format!("Job {job_id} not found")))
}
}
/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level
/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive).
/// Falls back to `[job_id]` if the row is absent so callers still run their probe.
async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Result<Vec<Uuid>> {
let chain_ids = sqlx::query_scalar!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT id AS "id!" FROM chain"#,
job_id,
w_id,
)
.fetch_all(db)
.await?;
Ok(if chain_ids.is_empty() {
vec![*job_id]
} else {
chain_ids
})
}
/// 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.
async fn validate_view_token(
db: &DB,
w_id: &str,
accessed_job_id: &Uuid,
token: &str,
) -> error::Result<bool> {
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
return Ok(false);
};
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
return Ok(false);
};
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
return Ok(false);
};
// Constant-time verification (same domain 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);
}
if accessed_job_id == &shared_id {
return Ok(true);
}
// 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))
}
lazy_static::lazy_static! {
/// Caches the result of the `require_job_read_access` RLS visibility probe,
/// keyed by the caller's authorization-relevant identity plus the job id (see
/// [`job_read_access_cache_key`]). No TTL: the cached decision is a pure function
/// of immutable job-side state and the caller-side state encoded in the key, so a
/// permission change re-keys rather than going stale. Size-bounded LRU.
static ref JOB_READ_ACCESS_CACHE: Cache<[u8; 32], bool> = Cache::new(50_000);
}
/// Key for [`JOB_READ_ACCESS_CACHE`]: a SHA-256 over every caller-side input that
/// affects job-read visibility (admin flag, username, username override, the sorted
/// group set, and the sorted folder set the caller has any grant on — RLS reads from
/// all of them) plus the workspace and job id. Sorting makes the key order-independent;
/// each variable-length field is length-prefixed so no choice of input values can make
/// two distinct identities hash equal (e.g. `["a","bc"]` vs `["ab","c"]`).
fn job_read_access_cache_key(authed: &ApiAuthed, w_id: &str, job_id: &Uuid) -> [u8; 32] {
let mut hasher = Sha256::new();
// Length-prefix every variable-length field (u32 BE) to make the encoding injective.
let field = |hasher: &mut Sha256, bytes: &[u8]| {
hasher.update((bytes.len() as u32).to_be_bytes());
hasher.update(bytes);
};
hasher.update([authed.is_admin as u8]);
field(&mut hasher, authed.username.as_bytes());
field(
&mut hasher,
authed.username_override.as_deref().unwrap_or("").as_bytes(),
);
let mut groups: Vec<&str> = authed.groups.iter().map(String::as_str).collect();
groups.sort_unstable();
hasher.update((groups.len() as u32).to_be_bytes());
for g in groups {
field(&mut hasher, g.as_bytes());
}
let mut folders: Vec<&str> = authed.folders.iter().map(|f| f.0.as_str()).collect();
folders.sort_unstable();
hasher.update((folders.len() as u32).to_be_bytes());
for f in folders {
field(&mut hasher, f.as_bytes());
}
field(&mut hasher, w_id.as_bytes());
hasher.update(job_id.as_bytes());
hasher.finalize().into()
}
/// [`require_job_read_access`] for callers (job-update poll / SSE) that haven't
/// already loaded `created_by` — fetches it (root DB, by id+workspace) first.
async fn require_job_update_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
view_token: Option<&str>,
) -> error::Result<()> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await
}
async fn get_job(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetJobQuery { no_logs, no_code, approval_token }): Query<GetJobQuery>,
) -> error::Result<Response> {
@@ -903,6 +1259,23 @@ 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?;
}
}
log_job_view(
&db,
opt_authed.as_ref(),
@@ -1477,8 +1850,10 @@ async fn get_logs_from_disk(
}
async fn get_completed_job_logs_tail(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<String> {
let tags = opt_authed
@@ -1501,7 +1876,18 @@ async fn get_completed_job_logs_tail(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
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(),
));
@@ -1519,9 +1905,11 @@ struct QueryJobLogs {
}
async fn get_job_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(query_job_logs): Query<QueryJobLogs>,
) -> error::Result<Response> {
@@ -1552,7 +1940,18 @@ async fn get_job_logs(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
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(),
));
@@ -1680,9 +2079,11 @@ async fn resolve_logs_to_string(
}
async fn get_flow_all_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -1702,7 +2103,18 @@ async fn get_flow_all_logs(
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
if opt_authed.is_none() && root_job.created_by != "anonymous" {
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(),
));
@@ -1858,9 +2270,11 @@ async fn get_flow_all_logs(
}
async fn get_args(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Box<RawValue>> {
let tags = opt_authed
@@ -1879,7 +2293,18 @@ async fn get_args(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
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(),
));
@@ -1907,7 +2332,18 @@ async fn get_args(
.fetch_optional(&db)
.await?;
let record = not_found_if_none(record, "Job Args", id.to_string())?;
if opt_authed.is_none() && record.created_by != "anonymous" {
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(),
));
@@ -2453,7 +2889,7 @@ pub async fn resume_suspended_flow_as_owner(
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
use windmill_common::variables::{generate_approval_token, generate_view_token};
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
@@ -7107,8 +7543,10 @@ pub async fn run_job_by_hash_inner(
}
async fn get_log_file(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, file_p)): Path<(String, String)>,
) -> error::Result<Response> {
if file_p.contains("..") {
@@ -7147,7 +7585,18 @@ async fn get_log_file(
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
if opt_authed.is_none() && created_by != "anonymous" {
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(),
));
@@ -7217,9 +7666,11 @@ async fn get_log_file(
}
async fn get_job_update(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7232,6 +7683,17 @@ async fn get_job_update(
..
}): Query<JobUpdateQuery>,
) -> JsonResult<JobUpdate> {
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
Ok(Json(
get_job_update_data(
&opt_authed,
@@ -7259,9 +7721,11 @@ async fn get_job_update(
}
async fn get_job_update_sse(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7275,6 +7739,20 @@ async fn get_job_update_sse(
poll_delay_ms,
}): Query<JobUpdateQuery>,
) -> error::Result<Response> {
// Authorize once at connection time; `created_by` cannot change for a given job,
// mirroring the per-stream `anonymous_verified` latch in the streaming loop.
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
let (tx, rx) = tokio::sync::mpsc::channel(32);
start_job_update_sse_stream(
@@ -8034,9 +8512,11 @@ async fn list_completed_jobs(
}
async fn get_completed_job<'a>(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -8051,6 +8531,20 @@ async fn get_completed_job<'a>(
.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(
&db,
&user_db,
authed,
&w_id,
&id,
&cj.created_by,
view_token.as_deref(),
)
.await?;
}
let response = Json(cj).into_response();
// let extra_log = query_scalar!(
// "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3",
@@ -8081,9 +8575,11 @@ pub struct RawResult {
}
async fn get_completed_job_result(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query<JsonPath>,
) -> error::Result<Response> {
@@ -8128,26 +8624,40 @@ async fn get_completed_job_result(
let mut raw_result = not_found_if_none(result_o, "Completed Job", id.to_string())?;
if opt_authed.is_none() && raw_result.created_by.unwrap_or_default() != "anonymous" {
match (suspended_job, resume_id, approver, secret) {
(Some(suspended_job), Some(resume_id), approver, Some(secret)) => {
let mut parent_job = id;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
if let Some(p_job) = p_job {
parent_job = p_job;
} else {
return Err(Error::BadRequest("Approval secret of suspended job is not a parent of the job whose id's is being searched not found".to_string()));
let created_by = raw_result.created_by.take().unwrap_or_default();
// A valid approval secret for the suspended parent flow grants access to this
// node's result for ANY caller — logged in or not — since the approval page
// renders its form from this result. Try it first. If the secret triple is absent,
// or present but invalid, fall through to normal authorization: an authenticated
// reader with ACL must NOT be blocked just because a stale/garbage secret was
// attached (pre-fix the secret branch was skipped entirely for authed callers),
// while an unauthenticated caller, for whom the secret is the only credential,
// still ends up rejected below.
let approval_secret_ok = match (suspended_job, resume_id, secret) {
(Some(suspended_job), Some(resume_id), Some(secret)) => {
// Walk from `id` up to the claimed suspended parent.
let mut parent_job = id;
let mut reached = true;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
match p_job {
Some(p_job) => parent_job = p_job,
None => {
reached = false;
break;
}
}
verify_suspended_secret(
}
reached
&& verify_suspended_secret(
&w_id,
&db,
suspended_job,
@@ -8155,14 +8665,28 @@ async fn get_completed_job_result(
&QueryApprover { approver, flow_level: None },
secret,
)
.await?
}
_ => {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
))
}
.await
.is_ok()
}
_ => false,
};
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(),
));
}
}
@@ -8235,9 +8759,11 @@ struct GetCompletedJobQuery {
}
async fn get_completed_job_result_maybe(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
) -> error::Result<Response> {
@@ -8263,7 +8789,18 @@ 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 opt_authed.is_none() && res.created_by != "anonymous" {
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(),
));
@@ -8286,6 +8823,36 @@ async fn get_completed_job_result_maybe(
})
.into_response())
} else if get_started.is_some_and(|x| x) {
// No completed row yet — the job may be queued/running. Returning its
// running-state still discloses information about a (possibly private) job, so
// authorize first when the job exists. If it doesn't exist, fall through to a
// `started: false` response (which leaks nothing).
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.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(),
));
}
}
let started = sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
id,
@@ -8320,8 +8887,10 @@ struct JobTiming {
}
async fn get_completed_job_timing(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<JobTiming> {
let tags = opt_authed
@@ -8347,7 +8916,18 @@ async fn get_completed_job_timing(
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
if opt_authed.is_none() && result.created_by != "anonymous" {
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(),
));
@@ -8367,7 +8947,7 @@ async fn delete_completed_job<'a>(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let mut tx = user_db.begin(&authed).await?;
let mut tx = user_db.clone().begin(&authed).await?;
require_admin(authed.is_admin, &authed.username)?;
let tags = get_scope_tags(&authed);
@@ -8411,17 +8991,21 @@ async fn delete_completed_job<'a>(
tx.commit().await?;
return get_completed_job(
OptViewToken(None),
OptAuthed(Some(authed)),
OptTokened { token: Some(token) },
Extension(db),
Extension(user_db),
Path((w_id, id)),
)
.await;
}
async fn get_otel_traces(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Json<Vec<serde_json::Value>>> {
// Check job exists and user has permission to view it
@@ -8435,7 +9019,18 @@ async fn get_otel_traces(
match job {
Some(created_by) => {
if opt_authed.is_none() && created_by != "anonymous" {
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(),
+85 -9
View File
@@ -39,23 +39,43 @@ pub struct StaticFile(Uri);
impl IntoResponse for StaticFile {
fn into_response(self) -> Response<Body> {
let original_path = self.0.path();
let query = self.0.query();
let path = original_path.trim_start_matches('/');
serve_path(path, original_path)
serve_path(path, original_path, query)
}
}
#[cfg(feature = "static_frontend")]
const TWO_HUNDRED: &str = "200.html";
/// Check if the original path requires cross-origin isolation headers
/// Check if the original path requires cross-origin isolation headers.
///
/// These headers are needed for SharedArrayBuffer and TypeScript workers
/// Only enabled for /apps_raw paths (raw app editor)
/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`).
///
/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep`
/// query param: a public (raw) app must set COEP to be embeddable as an iframe
/// inside a cross-origin-isolated page (which requires the embedded document to
/// also set COEP). It is opt-in rather than always-on because cross-origin
/// isolation also blocks subresources without CORP (e.g. external image URLs
/// or embeds used by classic apps), so we only enable it when the embedder
/// explicitly requests it.
#[cfg(feature = "static_frontend")]
fn needs_cross_origin_isolation(original_path: &str) -> bool {
original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/")
fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool {
original_path.starts_with("/apps_raw/")
|| original_path.starts_with("/ui_builder/")
|| ((original_path.starts_with("/public/") || original_path.starts_with("/a/"))
&& query_has_flag(query, "wm_coep"))
}
fn serve_path(path: &str, original_path: &str) -> Response<Body> {
/// Returns true if `query` contains the given flag key (with or without a
/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`.
#[cfg(feature = "static_frontend")]
fn query_has_flag(query: Option<&str>, flag: &str) -> bool {
query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag)))
}
fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response<Body> {
if path.starts_with("api/") {
return Response::builder().status(404).body(Body::empty()).unwrap();
}
@@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
// Add cross-origin isolation headers only for paths that need them
// (apps_raw editor needs SharedArrayBuffer for TypeScript workers)
if needs_cross_origin_isolation(original_path) {
if needs_cross_origin_isolation(original_path, query) {
res = res
.header("Cross-Origin-Opener-Policy", "same-origin")
.header("Cross-Origin-Embedder-Policy", "require-corp")
@@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
None if path.starts_with("_app/") => {
Response::builder().status(404).body(Body::empty()).unwrap()
}
None => serve_path(TWO_HUNDRED, original_path),
None => serve_path(TWO_HUNDRED, original_path, query),
}
#[cfg(not(feature = "static_frontend"))]
{
let _ = original_path; // suppress unused warning
let _ = (original_path, query); // suppress unused warning
Response::builder().status(404).body(Body::empty()).unwrap()
}
}
#[cfg(all(test, feature = "static_frontend"))]
mod tests {
use super::*;
#[test]
fn test_query_has_flag() {
assert!(query_has_flag(Some("wm_coep"), "wm_coep"));
assert!(query_has_flag(Some("wm_coep=on"), "wm_coep"));
assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep"));
assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep"));
assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep"));
assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep"));
assert!(!query_has_flag(Some(""), "wm_coep"));
assert!(!query_has_flag(None, "wm_coep"));
}
#[test]
fn test_needs_cross_origin_isolation() {
// editor + bundler are always isolated, regardless of query
assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None));
assert!(needs_cross_origin_isolation("/ui_builder/index.html", None));
// public apps (and custom paths) are isolated only when they opt in via wm_coep
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
Some("wm_coep")
));
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
Some("wm_coep=on")
));
assert!(needs_cross_origin_isolation(
"/a/ws/my/path",
Some("wm_coep=on")
));
assert!(!needs_cross_origin_isolation("/public/ws/secret", None));
assert!(!needs_cross_origin_isolation("/a/ws/my/path", None));
assert!(!needs_cross_origin_isolation(
"/public/ws/secret",
Some("foo=1")
));
// unrelated paths never get the headers
assert!(!needs_cross_origin_isolation(
"/apps/get/foo",
Some("wm_coep")
));
// `/api/` must not be caught by the `/a/` prefix
assert!(!needs_cross_origin_isolation(
"/api/version",
Some("wm_coep")
));
assert!(!needs_cross_origin_isolation("/", None));
}
}
+7 -7
View File
@@ -484,13 +484,13 @@ async fn update_username_in_workpsace<'c>(
).execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
w_id
).execute(&mut **tx)
.await?;
// NB: workspace_runnable_dependencies.app_path is intentionally NOT rewritten here.
// Its FK to app(path, workspace_id) is ON UPDATE CASCADE, so the `UPDATE app SET path`
// below propagates the new path automatically. Rewriting it manually here (before the
// app row is renamed) points the row at a not-yet-existing app path and violates
// fk_workspace_runnable_dependencies_app_path. (flow_path above DOES need the manual
// rewrite because flows are migrated via INSERT-new + DELETE-old, not UPDATE flow.path,
// so the cascade never fires for them.)
sqlx::query!(
r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
+27 -1
View File
@@ -282,6 +282,24 @@ pub async fn shutdown_signal(
Ok(())
}
// Defined for the whole non-unix scope (not just windows) so it can be a
// plain `tokio::select!` branch: that macro does not accept `#[cfg(...)]`
// attributes on individual branches. On non-windows non-unix targets the
// future never resolves, so the branch is effectively inert there.
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
async fn ctrl_break() -> std::io::Result<()> {
#[cfg(windows)]
{
tokio::signal::windows::ctrl_break()?.recv().await;
Ok(())
}
#[cfg(not(windows))]
{
std::future::pending::<()>().await;
Ok(())
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
tokio::select! {
_ = terminate() => {
@@ -297,7 +315,12 @@ pub async fn shutdown_signal(
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = tokio::signal::ctrl_c() => {
tracing::info!("shutdown monitor received ctrl-c");
},
_ = ctrl_break() => {
tracing::info!("shutdown monitor received ctrl-break");
},
_ = rx.recv() => {
tracing::info!("shutdown monitor received killpill");
},
@@ -319,6 +342,9 @@ pub async fn shutdown_signal(
_ = tokio::signal::ctrl_c() => {
tracing::error!("2nd shutdown monitor received ctrl-c")
},
_ = ctrl_break() => {
tracing::error!("2nd shutdown monitor received ctrl-break")
},
}
tracing::info!("Second terminate signal received, forcefully exiting");
+1
View File
@@ -393,6 +393,7 @@ pub async fn clone_script<'c>(
modules: s.modules,
auto_parent: None,
labels: s.labels,
skip_draft_deletion: None,
};
let new_hash = hash_script(&ns);
+20
View File
@@ -174,6 +174,26 @@ 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")`.
/// 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.
pub async fn generate_view_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::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");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
+1 -1
View File
@@ -157,7 +157,7 @@ pub enum ObjectType {
WorkspaceDependencies,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28238/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
+17 -1
View File
@@ -5058,7 +5058,7 @@ async fn push_inner<'c, 'd>(
content,
path,
hash,
language,
mut language,
lock,
cache_ttl,
cache_ignore_s3_path,
@@ -5068,6 +5068,21 @@ async fn push_inner<'c, 'd>(
debouncing_settings,
modules,
}) => {
// Reconcile the preview language with the `//native` annotation, mirroring the
// deploy-time logic in `worker_lockfiles`. The editor sends `bun` for a TypeScript
// script even when it carries `//native`, which would otherwise tag the preview as
// `bun` and route it to a regular bun worker. A native-mode worker neither matches
// the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native`
// script on a native-only worker setup fails. Normalizing to `bunnative` (tag
// `nativets`) makes the preview run exactly like the deployed script would.
if language == ScriptLang::Bun || language == ScriptLang::Bunnative {
let anns = windmill_common::worker::TypeScriptAnnotations::parse(&content);
if anns.native && language == ScriptLang::Bun {
language = ScriptLang::Bunnative;
} else if !anns.native && language == ScriptLang::Bunnative {
language = ScriptLang::Bun;
}
}
// Inject modules into job args as _MODULES so the worker can extract them
if let Some(ref modules) = modules {
match serde_json::to_string(modules).and_then(|s| RawValue::from_string(s)) {
@@ -5387,6 +5402,7 @@ async fn push_inner<'c, 'd>(
cache_ttl: cache_ttl.map(|val| val as u32),
cache_ignore_s3_path: cache_ignore_s3_path,
same_worker: false,
preserve_step_tags: false,
early_return: None,
skip_expr: None,
preprocessor_module: None,
@@ -337,6 +337,20 @@ mod zoom {
return Ok(None);
}
// Prevent this challenge endpoint from being used as a signing oracle.
// Legitimate Zoom validation tokens are short random hex strings that
// never contain colons. The exploit requires crafting a plainToken in the
// `v0:{timestamp}:{body}` webhook-signing format (always containing colons)
// to obtain a valid signature for an arbitrary body. Reject any token that
// does not look like a legitimate Zoom validation token.
if zoom_request_body.payload.plain_token.contains(':')
|| zoom_request_body.payload.plain_token.len() > 128
{
return Err(AuthenticationError::InvalidChallengeResponse(
"Zoom: invalid plainToken format".to_string(),
));
}
let hmac_signature = calculate_hmac_signature(
HmacAlgorithm::Sha256,
&signature_config_data.secret_key,
@@ -1540,6 +1554,52 @@ mod tests {
assert!(response.is_none());
}
#[test]
fn test_zoom_challenge_normal_token_succeeds() {
// A legitimate Zoom validation token is a short random alphanumeric string.
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#;
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let response = handler
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
.unwrap();
assert!(response.is_some());
}
#[test]
fn test_zoom_challenge_token_with_colons_rejected() {
// Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format
// would let an attacker obtain a valid webhook signature for an arbitrary body.
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#;
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload);
assert!(matches!(
result,
Err(AuthenticationError::InvalidChallengeResponse(_))
));
}
#[test]
fn test_zoom_challenge_token_too_long_rejected() {
// A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token.
let long_token = "a".repeat(129);
let payload = format!(
r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#,
long_token
);
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload);
assert!(matches!(
result,
Err(AuthenticationError::InvalidChallengeResponse(_))
));
}
// --- Custom webhook end-to-end ---
#[test]
+12
View File
@@ -110,6 +110,11 @@ pub struct NewFlow {
pub ws_error_handler_muted: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this flow must NOT delete an existing user draft at the same path.
/// Transient — never persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
impl NewFlow {
@@ -169,6 +174,13 @@ pub struct FlowValue {
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
pub same_worker: bool,
// When the flow runs on a custom worker tag, by default that tag is propagated to
// (and overrides) every step, script and nested sub-flow. Set this to true to instead
// let steps that declare their own non-empty tag run on it; steps without their own tag
// still inherit the flow's tag. Defaults to false to preserve the historical behavior.
#[serde(default)]
#[serde(skip_serializing_if = "is_default")]
pub preserve_step_tags: bool,
#[serde(flatten)]
pub concurrency_settings: ConcurrencySettings,
#[serde(flatten)]
+10
View File
@@ -540,9 +540,19 @@ pub struct NewScript {
pub auto_parent: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
/// this script must NOT delete an existing user draft at the same path.
/// Transient — never persisted. Deliberately excluded from `impl Hash`
/// below (it must not affect the version hash) and from the no-op
/// comparison in the deploy handler (it isn't part of what the script
/// *is*). See `is_noop_deploy_against_parent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_draft_deletion: Option<bool>,
}
// IMPORTANT: update this Hash impl when adding fields to NewScript
// (exception: caller-intent flags like `skip_draft_deletion` are intentionally
// omitted — they must not influence the computed version hash)
impl Hash for NewScript {
fn hash<H: Hasher>(&self, state: &mut H) {
self.path.hash(state);
@@ -8,7 +8,11 @@ time_limit: 900
rlimit_as: 2048
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
# uv's --compile-bytecode spawns a Python interpreter that compiles .py files
# with parallelism scaling to the host's CPU count, opening many fds at once.
# A low cap (was 64) is exhausted on high-core machines -> "Too many open files".
# Matches the runtime configs (run.python3/run.ansible) which already use 10000.
rlimit_nofile: 10000
envar: "HOME=/user"
envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH"
@@ -31,6 +31,7 @@ $PY_PATH
$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG
--system
--reinstall
--compile-bytecode
"
echo $CMD
@@ -2095,6 +2095,9 @@ async fn spawn_uv_install(
"--no-cache",
// If we invoke uv pip install, then we want to overwrite existing data
"--reinstall",
// Compile .py to .pyc at install time so imports are fast even
// through read-only nsjail mounts (no in-memory compilation per job).
"--compile-bytecode",
];
if let Some(py_path) = py_path.as_ref() {
+101 -8
View File
@@ -2985,6 +2985,87 @@ struct PushNextFlowJobRec {
// #[async_recursion]
// #[instrument(level = "trace", skip_all)]
/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor).
///
/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker
/// group. The exceptions, in order:
/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag,
/// always uses the child's own tag (`step_tag`);
/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag,
/// that tag is honored instead of being overridden by the flow tag;
/// - otherwise the child inherits the parent flow job's tag.
fn resolve_flow_step_tag(
is_preprocessor_step: bool,
flow_tag: &str,
workspace_id: &str,
preserve_step_tags: bool,
step_tag: Option<&str>,
) -> Option<String> {
if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) {
step_tag.map(str::to_string)
} else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) {
step_tag.map(str::to_string)
} else {
Some(flow_tag.to_string())
}
}
#[cfg(test)]
mod tag_resolution_tests {
use super::resolve_flow_step_tag;
#[test]
fn step_inherits_custom_flow_tag_by_default() {
// Parent flow on a custom tag, step declares its own tag, preserve disabled:
// the step inherits the flow tag (historical behavior).
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")),
Some("worker-group-A".to_string())
);
}
#[test]
fn step_keeps_own_tag_when_preserve_enabled() {
// The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a
// flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on.
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
#[test]
fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() {
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, None),
Some("worker-group-A".to_string())
);
// An empty tag counts as "no tag" and still inherits.
assert_eq!(
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")),
Some("worker-group-A".to_string())
);
}
#[test]
fn generic_flow_tag_always_uses_step_tag() {
for flow_tag in ["flow", "flow-w1"] {
assert_eq!(
resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
}
#[test]
fn preprocessor_step_uses_step_tag() {
assert_eq!(
resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")),
Some("worker-group-B".to_string())
);
}
}
async fn push_next_flow_job(
flow_job: Arc<MiniPulledJob>,
mut status: FlowStatus,
@@ -4118,13 +4199,13 @@ async fn push_next_flow_job(
.map(|x| x.into());
tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}");
let tag = if step.is_preprocessor_step()
|| (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id))
{
payload_tag.tag.clone()
} else {
Some(flow_job.tag.clone())
};
let tag = resolve_flow_step_tag(
step.is_preprocessor_step(),
&flow_job.tag,
&flow_job.workspace_id,
flow.preserve_step_tags,
payload_tag.tag.as_deref(),
);
let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref()
{
@@ -4822,6 +4903,7 @@ fn payload_from_modules<'a>(
modules_node: Option<FlowNodeId>,
failure_module: Option<&Box<FlowModule>>,
same_worker: bool,
preserve_step_tags: bool,
id: impl FnOnce() -> String,
path: impl FnOnce() -> String,
opt_empty_inner_flows: bool,
@@ -4842,7 +4924,13 @@ fn payload_from_modules<'a>(
}
Some(JobPayload::RawFlow {
value: FlowValue { modules, failure_module, same_worker, ..Default::default() },
value: FlowValue {
modules,
failure_module,
same_worker,
preserve_step_tags,
..Default::default()
},
path: Some(path()),
restarted_from: None,
})
@@ -5144,6 +5232,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{i}", status.step),
|| format!("{}/forloop-{i}", flow_job.runnable_path()),
true,
@@ -5280,6 +5369,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| status.step.to_string(),
|| format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx),
true,
@@ -5321,6 +5411,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{i}", status.step),
|| format!("{}/branchall-{}", flow_job.runnable_path(), i),
false,
@@ -5391,6 +5482,7 @@ async fn compute_next_flow_transform(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{}", status.step, branch_status.branch),
|| {
format!(
@@ -5473,6 +5565,7 @@ async fn next_loop_iteration(
modules_node,
flow.failure_module.as_ref(),
flow.same_worker,
flow.preserve_step_tags,
|| format!("{}-{}", status.step, ns.index),
inner_path,
true,
+1 -1
View File
@@ -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.712.0";
export const VERSION = "v1.714.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+6
View File
@@ -192,6 +192,8 @@ export async function pushApp(
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -205,6 +207,8 @@ export async function pushApp(
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -480,6 +484,8 @@ const command = new Command()
on_behalf_of_email: email,
} as any,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`));
+4
View File
@@ -466,6 +466,8 @@ export async function pushRawApp(
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
@@ -486,6 +488,8 @@ export async function pushRawApp(
summary: localApp.summary,
policy: appForPolicy.policy,
deployment_message: message,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
...(localApp.custom_path
? { custom_path: localApp.custom_path }
: {}),
+6
View File
@@ -224,6 +224,8 @@ export async function pushFlow(
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
@@ -237,6 +239,8 @@ export async function pushFlow(
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
} catch (e) {
@@ -1159,6 +1163,8 @@ const command = new Command()
path: flowPath,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
} as any,
});
log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`));
+5
View File
@@ -758,6 +758,9 @@ async function createScript(
workspace: Workspace
): Promise<number> {
const start = performance.now();
// Preserve any user draft at this path: a CLI / git-sync deploy must not wipe
// an in-progress draft the way a UI "deploy from draft" intentionally does.
body = { ...body, skip_draft_deletion: true };
// skip_if_noop asks the backend to treat deploys identical to the parent
// (same content, lockfile, and metadata) as a no-op, so the CLI does not
// produce phantom git-sync / promotion commits on re-pushes.
@@ -1796,6 +1799,8 @@ async function setPermissionedAs(
parent_hash: remote.hash,
on_behalf_of_email: email,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`));
+19 -6
View File
@@ -3053,12 +3053,13 @@ export async function gitDeploy(
...(opts.extraIncludes ?? []),
...includes.extraIncludes,
],
includeSchedules: opts.includeSchedules || includes.includeSchedules,
includeGroups: opts.includeGroups || includes.includeGroups,
includeUsers: opts.includeUsers || includes.includeUsers,
includeTriggers: opts.includeTriggers || includes.includeTriggers,
includeSettings: opts.includeSettings || includes.includeSettings,
includeKey: opts.includeKey || includes.includeKey,
// Workspace-wide mode force-includes the deployed default-excluded kinds
// (full mirror). Individual-branch/promotion mode forces nothing — these
// keys stay ABSENT so pull resolves them from the promotion target's
// effective wmill.yaml filters. Spreading (not setting `false`) is what
// makes the deferral work: an explicit `false` would clobber the effective
// config in pull's Object.assign-based option merge.
...includes.forcedIncludes,
promotion,
} as any);
}
@@ -4041,6 +4042,10 @@ export async function push(
originalWorkspaceSpecificPath,
permissionedAsContext,
isWsSpecific ? true : undefined,
{
noninteractive: (opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
);
if (stateTarget) {
@@ -4126,6 +4131,10 @@ export async function push(
localFilePath, // Pass the actual local file path
permissionedAsContext,
isAddedWsSpecific ? true : undefined,
{
noninteractive: (opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
);
if (stateTarget) {
@@ -4682,6 +4691,10 @@ const command = new Command()
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option("--include-key", "Include workspace encryption key")
.option(
"--skip-reencrypt-on-key-change",
"When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
+1
View File
@@ -88,6 +88,7 @@ export interface SyncOptions {
includeGroups?: boolean;
includeSettings?: boolean;
includeKey?: boolean;
skipReencryptOnKeyChange?: boolean;
skipBranchValidation?: boolean;
message?: string;
includes?: string[];
+48 -7
View File
@@ -445,11 +445,23 @@ export async function pushWorkspaceSettings(
}
}
export interface PushWorkspaceKeyOptions {
// True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a
// TTY). In that case the re-encryption decision is taken from `skipReencrypt`
// / the WMILL_NO_REENCRYPT_ON_KEY_CHANGE env var instead of an interactive
// confirmation.
noninteractive?: boolean;
// Explicit re-encryption decision from `--skip-reencrypt-on-key-change`.
// When set it takes precedence over the prompt and the env var.
skipReencrypt?: boolean;
}
export async function pushWorkspaceKey(
workspace: string,
_path: string,
key: string | undefined,
localKey: string
localKey: string,
opts?: PushWorkspaceKeyOptions
) {
try {
key = await wmill
@@ -461,17 +473,46 @@ export async function pushWorkspaceKey(
throw new Error(`Failed to get workspace encryption key: ${err}`);
}
if (localKey && key !== localKey) {
const confirm = await Confirm.prompt({
message:
"The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n",
default: true,
});
// Changing the key on the remote means the existing ciphertexts (encrypted
// with the old key) become unreadable unless they are re-encrypted. By
// default we ask the backend to re-encrypt every secret variable with the
// new key, which preserves their plaintext values. The only reason to skip
// re-encryption is when the stored ciphertexts are *already* encrypted with
// the new key (e.g. a workspace/instance migration).
let reencrypt: boolean;
// Explicit choice via `--skip-reencrypt-on-key-change` or the env var wins
// over everything, regardless of interactivity.
const explicitSkip =
opts?.skipReencrypt ||
(process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE ?? "").toLowerCase() ===
"true";
if (explicitSkip) {
reencrypt = false;
log.info(
"Workspace encryption key changed; leaving remote ciphertexts untouched (skip re-encryption requested)."
);
} else if (opts?.noninteractive) {
// No TTY (or --yes) and no explicit skip: we can't prompt, so default to
// re-encrypting (matches the interactive default) to preserve secret
// values. Pass --skip-reencrypt-on-key-change (or set
// WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true) to opt out.
reencrypt = true;
log.info(
"Workspace encryption key changed; re-encrypting all remote secrets with the new key (non-interactive)."
);
} else {
reencrypt = await Confirm.prompt({
message:
"The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n",
default: true,
});
}
log.debug(`Updating workspace encryption key...`);
await wmill.setWorkspaceEncryptionKey({
workspace,
requestBody: {
new_key: localKey,
skip_reencrypt: !confirm,
skip_reencrypt: !reencrypt,
},
});
} else {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -89,7 +89,7 @@ export {
token,
};
export const VERSION = "1.712.0";
export const VERSION = "1.714.1";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+8 -2
View File
@@ -18,7 +18,11 @@ import { pushSchedule } from "./commands/schedule/schedule.ts";
import { pushWorkspaceUser } from "./commands/user/user.ts";
import { pushGroup } from "./commands/user/user.ts";
import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts";
import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts";
import {
pushWorkspaceSettings,
pushWorkspaceKey,
PushWorkspaceKeyOptions,
} from "./core/settings.ts";
import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts";
import { pushRawApp } from "./commands/app/raw_apps.ts";
import type { PermissionedAsContext } from "./core/permissioned_as.ts";
@@ -179,6 +183,7 @@ function redactString(s: string): string {
* @param alreadySynced - Array to track already synced items
* @param message - Optional commit/update message
* @param originalLocalPath - The original local file path (used for branch-specific resource file resolution)
* @param keyPushOpts - Options for the encryption_key push: non-interactive flag and explicit re-encryption choice
*/
export async function pushObj(
workspace: string,
@@ -191,6 +196,7 @@ export async function pushObj(
originalLocalPath?: string,
permissionedAsContext?: PermissionedAsContext,
wsSpecific?: boolean,
keyPushOpts?: PushWorkspaceKeyOptions,
) {
const typeEnding = getTypeStrFromPath(p);
@@ -256,7 +262,7 @@ export async function pushObj(
} else if (typeEnding === "settings") {
await pushWorkspaceSettings(workspace, p, befObj, newObj);
} else if (typeEnding === "encryption_key") {
await pushWorkspaceKey(workspace, p, befObj, newObj);
await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts);
} else {
throw new Error(
`The item ${p} has an unrecognized type ending ${typeEnding}`
+40 -16
View File
@@ -252,21 +252,44 @@ export function gitSyncIncludePattern(
}
}
export interface GitSyncDeployIncludes {
extraIncludes: string[];
// `forcedIncludes` carries ONLY the include-* flags that must be force-set to
// true (overriding the repo's wmill.yaml). Kinds not present are intentionally
// omitted (never set to false) so the caller can spread this object and let
// the repo's effective config govern the rest — see deriveGitSyncDeployIncludes.
export type GitSyncForcedIncludes = Partial<{
includeSchedules: boolean;
includeGroups: boolean;
includeUsers: boolean;
includeTriggers: boolean;
includeSettings: boolean;
includeKey: boolean;
}>;
export interface GitSyncDeployIncludes {
extraIncludes: string[];
forcedIncludes: GitSyncForcedIncludes;
}
// Mirrors the hub script's wmill_sync_pull include-derivation: build the
// --extra-includes set from the deployed items, and (only in workspace-wide
// mode — never with --use-individual-branch) opt object kinds that are
// excluded by default back in. Replaces the script's regexFromPath +
// --extra-includes set from the deployed items, and decide which default-
// excluded object kinds (triggers, schedules, groups, users, settings, key)
// must be force-included in the pull. Replaces the script's regexFromPath +
// per-kind --include-* construction so the hub script can drop both.
//
// Branch-mode distinction (this is load-bearing — see the trigger-promotion
// bug it fixes):
// - Workspace-wide mode: the repo is a full mirror of the workspace, so a
// deployed object of a default-excluded kind MUST be re-included, even if
// wmill.yaml would otherwise skip it. We force the flag on.
// - Individual-branch (promotion) mode: the repo is a filtered prod surface
// whose own wmill.yaml filters decide what gets promoted. We force NOTHING
// here and the keys stay absent, so the caller's pull resolves them from
// the target's effective config (a deployed trigger lands iff the target
// includes triggers). Forcing `false` (the original behavior) did NOT
// defer — it CLOBBERED the effective config via Object.assign in pull's
// option merge, silently dropping kinds the target actually wanted (e.g. a
// deployed trigger when the target has includeTriggers: true), and the
// server then omitted the object from the tarball entirely.
export function deriveGitSyncDeployIncludes(
items: GitSyncDeployItem[],
useIndividualBranch: boolean,
@@ -283,18 +306,19 @@ export function deriveGitSyncDeployIncludes(
}
}
const has = (pred: (t: string) => boolean) =>
!useIndividualBranch && items.some((i) => pred(i.path_type));
const forcedIncludes: GitSyncForcedIncludes = {};
if (!useIndividualBranch) {
const has = (pred: (t: string) => boolean) =>
items.some((i) => pred(i.path_type));
if (has((t) => t === "schedule")) forcedIncludes.includeSchedules = true;
if (has((t) => t === "group")) forcedIncludes.includeGroups = true;
if (has((t) => t === "user")) forcedIncludes.includeUsers = true;
if (has((t) => t.includes("trigger"))) forcedIncludes.includeTriggers = true;
if (has((t) => t === "settings")) forcedIncludes.includeSettings = true;
if (has((t) => t === "key")) forcedIncludes.includeKey = true;
}
return {
extraIncludes,
includeSchedules: has((t) => t === "schedule"),
includeGroups: has((t) => t === "group"),
includeUsers: has((t) => t === "user"),
includeTriggers: has((t) => t.includes("trigger")),
includeSettings: has((t) => t === "settings"),
includeKey: has((t) => t === "key"),
};
return { extraIncludes, forcedIncludes };
}
function git(
+47 -11
View File
@@ -254,7 +254,7 @@ describe("deriveGitSyncDeployIncludes", () => {
]);
});
test("workspace-wide mode opts excluded kinds back in", () => {
test("workspace-wide mode force-includes deployed default-excluded kinds", () => {
const r = deriveGitSyncDeployIncludes(
[
{ path_type: "schedule", path: "f/s" },
@@ -266,15 +266,36 @@ describe("deriveGitSyncDeployIncludes", () => {
],
false
);
expect(r.includeSchedules).toBe(true);
expect(r.includeGroups).toBe(true);
expect(r.includeTriggers).toBe(true);
expect(r.includeSettings).toBe(true);
expect(r.includeKey).toBe(true);
expect(r.includeUsers).toBe(true);
// Full-mirror repo: a deployed object of a default-excluded kind must be
// re-included even if wmill.yaml would skip it, so the flag is forced on.
expect(r.forcedIncludes).toEqual({
includeSchedules: true,
includeGroups: true,
includeTriggers: true,
includeSettings: true,
includeKey: true,
includeUsers: true,
});
});
test("individual-branch mode NEVER sets include flags (matches hub script)", () => {
test("workspace-wide mode only forces the kinds actually deployed", () => {
const r = deriveGitSyncDeployIncludes(
[{ path_type: "script", path: "f/s" }],
false
);
// Scripts are included by default — nothing to force.
expect(r.forcedIncludes).toEqual({});
});
test("individual-branch (promotion) mode forces NOTHING — defers to wmill.yaml", () => {
// Regression: these flags used to be force-disabled (set to false) in
// promotion mode, which CLOBBERED the promotion target's effective
// wmill.yaml config (an explicit false wins in pull's Object.assign merge).
// The server then stripped the object from the tarball, the pull wrote
// nothing, and `git add '<path>**'` failed with "pathspec did not match
// any files". Forcing nothing leaves the keys absent so the target's
// effective filters govern; extraIncludes still scopes the pull to the
// changed object.
const r = deriveGitSyncDeployIncludes(
[
{ path_type: "schedule", path: "f/s" },
@@ -282,12 +303,27 @@ describe("deriveGitSyncDeployIncludes", () => {
],
true
);
expect(r.includeSchedules).toBe(false);
expect(r.includeTriggers).toBe(false);
// extra-includes are still derived regardless of branch mode
expect(r.forcedIncludes).toEqual({});
expect(r.extraIncludes).toContain("f/s.schedule.*");
expect(r.extraIncludes).toContain("f/t.kafka_trigger.*");
});
test("regression: http_trigger promotion deploy does not clobber the target's includeTriggers", () => {
// Brad's scenario: an HTTP trigger is deployed and the promotion repo uses
// individual branches. path_type is "httptrigger" (the no-underscore value
// the backend puts on item.path_type — see git_sync_ee.rs
// insert_path_type_and_return_message). includeTriggers must NOT be forced
// false here, so the target's effective includeTriggers (true in Brad's
// config) is honored and the trigger file is pulled and committed.
const r = deriveGitSyncDeployIncludes(
[{ path_type: "httptrigger", path: "f/platform/on_call_chat_http_route" }],
true
);
expect(r.forcedIncludes.includeTriggers).toBeUndefined();
expect(r.extraIncludes).toContain(
"f/platform/on_call_chat_http_route.http_trigger.*"
);
});
});
// =============================================================================
+375
View File
@@ -22,6 +22,18 @@ import { withTestBackend } from "./test_backend.ts";
import { shouldSkipOnCI } from "./cargo_backend.ts";
import { addWorkspace } from "../workspace.ts";
// The HTTP-trigger promotion test creates an http_trigger, whose API routes are
// behind the `http_trigger` cargo feature — NOT in the default EE test feature
// set. The shared test backend reads TEST_FEATURES at construction (first
// `withTestBackend` call), so appending here at module load enables it. Guarded
// on shouldSkipOnCI() so we only widen the build when these EE tests actually
// run (i.e. EE_LICENSE_KEY present); minimal CI builds stay untouched.
if (!shouldSkipOnCI()) {
process.env["TEST_FEATURES"] = [process.env["TEST_FEATURES"], "http_trigger"]
.filter(Boolean)
.join(",");
}
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
@@ -44,6 +56,24 @@ function remoteHead(bareDir: string, branch: string): string {
).trim();
}
// True if `filePath` exists in the tree of `branch` on the bare remote.
function fileExistsOnBranch(
bareDir: string,
branch: string,
filePath: string,
): boolean {
try {
execFileSync(
"git",
["--git-dir", bareDir, "cat-file", "-e", `refs/heads/${branch}:${filePath}`],
{ stdio: "ignore" },
);
return true;
} catch {
return false;
}
}
test.skipIf(shouldSkipOnCI())(
"git-sync promotion: use_individual_branch pushes to wm_deploy branch, not main",
async () => {
@@ -201,6 +231,351 @@ test.skipIf(shouldSkipOnCI())(
},
);
/**
* Regression test for the promotion trigger-include bug (fix/gitsync-promotion-
* trigger-export): deploying a trigger (or any excluded-by-default kind:
* schedule, group, user, settings, key) with `use_individual_branch` must still
* land the object file on the `wm_deploy` branch.
*
* Root cause: `deriveGitSyncDeployIncludes` used to force the per-kind include
* flags (`includeTriggers` etc.) to false in individual-branch mode. The
* server-side tarball export STRIPS those object kinds entirely when their
* include flag is false (`if include_triggers { … }` in workspaces_export.rs),
* and `extraIncludes` is only a client-side filter over what the tarball
* already contains it can't recover a file the server never sent. So the
* pull wrote no trigger file, the wm_deploy branch was created empty of the
* trigger, and production's `git add '<path>**'` failed with "pathspec did not
* match any files". A script (always-included kind) never hit this hence the
* dedicated trigger case here.
*
* Without the fix this test fails: the branch exists but the
* `*.http_trigger.yaml` file is absent from it.
*/
test.skipIf(shouldSkipOnCI())(
"git-sync promotion: use_individual_branch lands a trigger file on the wm_deploy branch",
async () => {
await withTestBackend(async (backend) => {
const ws = backend.workspace; // "test"
await addWorkspace(
{
remote: backend.baseUrl,
workspaceId: ws,
name: ws,
token: backend.token,
} as any,
{ force: true, configDir: backend.testConfigDir },
);
// --- 1. Bare "remote" seeded with an initial `main` commit ---
const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_bare_"));
execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]);
const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_seed_"));
git(seedDir, "init", "--initial-branch=main");
git(seedDir, "config", "user.email", "seed@windmill.dev");
git(seedDir, "config", "user.name", "seed");
await writeFile(join(seedDir, "README.md"), "# promo trigger test\n");
git(seedDir, "add", "-A");
git(seedDir, "commit", "-m", "seed");
git(seedDir, "remote", "add", "origin", `file://${bareDir}`);
git(seedDir, "push", "-u", "origin", "main");
const seedMain = remoteHead(bareDir, "main");
// --- 2. Workspace content: a script + an HTTP trigger pointing at it ---
await backend.apiRequest!(`/api/w/${ws}/folders/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }),
});
await backend.apiRequest!(`/api/w/${ws}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/promo/foo",
summary: "",
description: "",
content: "export async function main() { return 1 }",
language: "bun",
}),
});
const trigRes = await backend.apiRequest!(`/api/w/${ws}/http_triggers/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/promo/hook",
script_path: "f/promo/foo",
route_path: "promo_hook",
is_flow: false,
http_method: "post",
authentication_method: "none",
is_static_website: false,
request_type: "sync",
}),
});
// Guard against the route silently 404ing (the http_trigger cargo feature
// not being built) — otherwise the pull below would find nothing to sync
// and the real assertion would fail with a confusing message.
expect(trigRes.status).toBe(201);
// --- 3. git_repository resource + git-sync config (triggers included) ---
await backend.apiRequest!(`/api/w/${ws}/resources/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "u/test/promo_repo",
resource_type: "git_repository",
value: { url: `file://${bareDir}`, branch: "main", token: "" },
}),
});
await backend.updateGitSyncConfig!({
git_sync_settings: {
repositories: [
{
git_repo_resource_path: "u/test/promo_repo",
script_path: "f/**",
use_individual_branch: true,
group_by_folder: false,
settings: {
include_path: ["f/**"],
include_type: ["script", "trigger"],
},
},
],
},
});
// The backend sets path_type "httptrigger" (no underscore) on the deploy
// item — see DeployedObject::HttpTrigger => "httptrigger" in git_sync_ee.rs.
const deployItems = JSON.stringify([
{
path_type: "httptrigger",
path: "f/promo/hook",
commit_msg: "deploy hook",
},
]);
const work = await mkdtemp(join(tmpdir(), "wmill_promo_trig_work_"));
git(work, "clone", `file://${bareDir}`, ".");
// Option B semantic: in promotion mode the deploy forces NOTHING — the
// trigger lands only because THIS target's effective wmill.yaml opts
// triggers in. (Reverting the source fix re-introduces the force-`false`
// that clobbers this `includeTriggers: true`, so the file is dropped.)
await writeFile(
join(work, "wmill.yaml"),
"defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeTriggers: true\n",
);
const res = await backend.runCLICommand(
[
"sync",
"git-deploy",
"--repository",
"u/test/promo_repo",
"--use-individual-branch",
"--git-deploy-items",
deployItems,
],
work,
);
expect(res.code).toBe(0);
// Caller-half (mirrors the hub script): stage what the pull wrote, commit
// on the checked-out wm_deploy branch, push.
git(work, "config", "user.email", "test@windmill.dev");
git(work, "config", "user.name", "test");
git(work, "add", "-A");
try {
git(work, "diff", "--cached", "--quiet");
} catch {
git(work, "commit", "-m", "deploy hook");
}
git(work, "push", "--porcelain", "-u", "origin", "HEAD");
const expectedBranch = `refs/heads/wm_deploy/${ws}/httptrigger/f__promo__hook`;
expect(remoteBranches(bareDir)).toContain(expectedBranch);
// The regression: the trigger file MUST be present on the branch. Without
// the fix the include flag is false, the server strips the trigger from
// the tarball, the pull writes nothing, and this file is absent.
expect(
fileExistsOnBranch(
bareDir,
`wm_deploy/${ws}/httptrigger/f__promo__hook`,
"f/promo/hook.http_trigger.yaml",
),
).toBe(true);
// Base branch untouched (individual-branch never pushes to the base).
expect(remoteHead(bareDir, "main")).toBe(seedMain);
await rm(bareDir, { recursive: true, force: true });
await rm(seedDir, { recursive: true, force: true });
await rm(work, { recursive: true, force: true });
});
},
);
/**
* Same regression as the HTTP-trigger case above, for a `schedule` a
* different excluded-by-default kind that exercises a DISTINCT path: its own
* include flag (`includeSchedules`), its own server-side `if include_schedules`
* tarball-strip branch, and its own `.schedule.yaml` extension. Unlike triggers
* it needs no extra cargo feature, so it guards the fix even where the
* trigger-specific features aren't built.
*
* Without the fix this test fails: the branch exists but the
* `*.schedule.yaml` file is absent from it.
*/
test.skipIf(shouldSkipOnCI())(
"git-sync promotion: use_individual_branch lands a schedule file on the wm_deploy branch",
async () => {
await withTestBackend(async (backend) => {
const ws = backend.workspace; // "test"
await addWorkspace(
{
remote: backend.baseUrl,
workspaceId: ws,
name: ws,
token: backend.token,
} as any,
{ force: true, configDir: backend.testConfigDir },
);
// --- 1. Bare "remote" seeded with an initial `main` commit ---
const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_bare_"));
execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]);
const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_seed_"));
git(seedDir, "init", "--initial-branch=main");
git(seedDir, "config", "user.email", "seed@windmill.dev");
git(seedDir, "config", "user.name", "seed");
await writeFile(join(seedDir, "README.md"), "# promo schedule test\n");
git(seedDir, "add", "-A");
git(seedDir, "commit", "-m", "seed");
git(seedDir, "remote", "add", "origin", `file://${bareDir}`);
git(seedDir, "push", "-u", "origin", "main");
const seedMain = remoteHead(bareDir, "main");
// --- 2. Workspace content: a script + a (disabled) schedule for it ---
await backend.apiRequest!(`/api/w/${ws}/folders/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }),
});
await backend.apiRequest!(`/api/w/${ws}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/promo/foo",
summary: "",
description: "",
content: "export async function main() { return 1 }",
language: "bun",
}),
});
const schedRes = await backend.apiRequest!(`/api/w/${ws}/schedules/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/promo/sched",
schedule: "0 0 12 * * *",
timezone: "UTC",
script_path: "f/promo/foo",
is_flow: false,
args: {},
enabled: false,
}),
});
expect(schedRes.status).toBe(200);
// --- 3. git_repository resource + git-sync config (schedules included) ---
await backend.apiRequest!(`/api/w/${ws}/resources/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "u/test/promo_repo",
resource_type: "git_repository",
value: { url: `file://${bareDir}`, branch: "main", token: "" },
}),
});
await backend.updateGitSyncConfig!({
git_sync_settings: {
repositories: [
{
git_repo_resource_path: "u/test/promo_repo",
script_path: "f/**",
use_individual_branch: true,
group_by_folder: false,
settings: {
include_path: ["f/**"],
include_type: ["script", "schedule"],
},
},
],
},
});
const deployItems = JSON.stringify([
{
path_type: "schedule",
path: "f/promo/sched",
commit_msg: "deploy sched",
},
]);
const work = await mkdtemp(join(tmpdir(), "wmill_promo_sched_work_"));
git(work, "clone", `file://${bareDir}`, ".");
// Option B semantic: in promotion mode the deploy forces NOTHING — the
// schedule lands only because THIS target's effective wmill.yaml opts
// schedules in. (Reverting the source fix re-introduces the force-`false`
// that clobbers this `includeSchedules: true`, so the file is dropped.)
await writeFile(
join(work, "wmill.yaml"),
"defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeSchedules: true\n",
);
const res = await backend.runCLICommand(
[
"sync",
"git-deploy",
"--repository",
"u/test/promo_repo",
"--use-individual-branch",
"--git-deploy-items",
deployItems,
],
work,
);
expect(res.code).toBe(0);
// Caller-half (mirrors the hub script): stage what the pull wrote, commit
// on the checked-out wm_deploy branch, push.
git(work, "config", "user.email", "test@windmill.dev");
git(work, "config", "user.name", "test");
git(work, "add", "-A");
try {
git(work, "diff", "--cached", "--quiet");
} catch {
git(work, "commit", "-m", "deploy sched");
}
git(work, "push", "--porcelain", "-u", "origin", "HEAD");
const expectedBranch = `refs/heads/wm_deploy/${ws}/schedule/f__promo__sched`;
expect(remoteBranches(bareDir)).toContain(expectedBranch);
// The regression: the schedule file MUST be present on the branch. Without
// the fix the include flag is false, the server strips the schedule from
// the tarball, the pull writes nothing, and this file is absent.
expect(
fileExistsOnBranch(
bareDir,
`wm_deploy/${ws}/schedule/f__promo__sched`,
"f/promo/sched.schedule.yaml",
),
).toBe(true);
// Base branch untouched (individual-branch never pushes to the base).
expect(remoteHead(bareDir, "main")).toBe(seedMain);
await rm(bareDir, { recursive: true, force: true });
await rm(seedDir, { recursive: true, force: true });
await rm(work, { recursive: true, force: true });
});
},
);
/**
* Regression test for WIN-1997: forking a workspace with git sync configured
* must publish a `wm-fork/<branch>/<id>` branch to the remote.
+93
View File
@@ -0,0 +1,93 @@
/**
* Unit tests for pushWorkspaceKey in settings.ts.
*
* Covers WIN-2005: changing the encryption key in encryption_key.yaml and
* pushing it must (by default) re-encrypt the remote secrets with the new key.
*
* Verifies that:
* - an unchanged key is a no-op (no setWorkspaceEncryptionKey call)
* - a changed key in non-interactive mode re-encrypts by default
* (skip_reencrypt = false), so secret plaintext values are preserved
* - the --skip-reencrypt-on-key-change flag keeps the remote ciphertexts
* untouched (skip_reencrypt = true)
* - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var
*/
import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test";
// Track calls to mocked wmill functions
let remoteKey = "";
let setEncryptionKeyCalls: {
workspace: string;
requestBody: { new_key: string; skip_reencrypt?: boolean };
}[] = [];
// Mock the wmill module before importing settings.ts
mock.module("../gen/services.gen.ts", () => ({
getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({
key: remoteKey,
}),
setWorkspaceEncryptionKey: async (args: {
workspace: string;
requestBody: { new_key: string; skip_reencrypt?: boolean };
}) => {
setEncryptionKeyCalls.push(args);
},
}));
import { pushWorkspaceKey } from "../src/core/settings.ts";
describe("pushWorkspaceKey", () => {
const ws = "test-workspace";
beforeEach(() => {
remoteKey = "";
setEncryptionKeyCalls = [];
delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE;
});
afterEach(() => {
delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE;
});
test("no-op when local key matches the remote key", async () => {
remoteKey = "samekey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "samekey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(0);
});
test("changed key re-encrypts by default in non-interactive mode", async () => {
remoteKey = "oldkey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
// skip_reencrypt false => backend re-encrypts existing secrets with new key
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(false);
});
test("--skip-reencrypt-on-key-change skips re-encryption", async () => {
remoteKey = "oldkey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
skipReencrypt: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true);
});
test("WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true skips re-encryption non-interactively", async () => {
remoteKey = "oldkey";
process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE = "true";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true);
});
});
+3 -3
View File
@@ -26,7 +26,7 @@ RUN make
FROM ${DEBIAN_IMAGE}
ARG APP=/usr/src/app
ARG LATEST_STABLE_PY=3.11.10
ARG LATEST_STABLE_PY=3.12.12
# UV configuration
ENV UV_CACHE_DIR=/tmp/windmill/cache/uv
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -83,7 +83,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+3 -3
View File
@@ -26,7 +26,7 @@ RUN make
FROM ${DEBIAN_IMAGE}
ARG APP=/usr/src/app
ARG LATEST_STABLE_PY=3.11.10
ARG LATEST_STABLE_PY=3.12.12
# UV configuration
ENV UV_CACHE_DIR=/tmp/windmill/cache/uv
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -83,7 +83,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
-1
View File
@@ -1,6 +1,5 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG RUST_IMAGE=registry.access.redhat.com/ubi8/ubi:latest
ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm
FROM ${RUST_IMAGE} AS rust_base
-1
View File
@@ -1,6 +1,5 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG RUST_IMAGE=registry.access.redhat.com/ubi9/ubi:latest
ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm
FROM ${RUST_IMAGE} AS rust_base
+32 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.714.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.714.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -66,6 +66,7 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
@@ -6352,6 +6353,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
@@ -10888,6 +10904,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.712.0",
"version": "1.714.1",
"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",
@@ -126,7 +126,6 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"unist-util-visit": "^5.0.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -141,12 +140,14 @@
"quill": "^1.3.7",
"rehype-github-alerts": "^3.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rfc4648": "^1.5.3",
"runed": "^0.36.0",
"svelte-carousel": "^1.0.25",
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tailwind-merge": "^1.13.2",
"unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
@@ -84,7 +84,8 @@
'zoho',
'xero',
'apify',
'docusign'
'docusign',
'salesforce'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
@@ -8,6 +8,7 @@
import { editor as meditor, KeyMod, KeyCode } from 'monaco-editor'
import { initializeVscode } from './vscode'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { registerWebviewPaste } from '$lib/editorUtils'
import EditorTheme from './EditorTheme.svelte'
import Button from '$lib/components/common/button/Button.svelte'
@@ -70,6 +71,7 @@
scrollBeyondLastLine: false,
lineDecorationsWidth: 15,
lineNumbersMinChars: 2,
fontSize: editorFontSize.regular,
scrollbar: { alwaysConsumeMouseWheel: false }
})
@@ -175,6 +177,13 @@
}
})
$effect(() => {
const fontSize = editorFontSize.regular
if (diffEditor) {
diffEditor.updateOptions({ fontSize })
}
})
$effect(() => {
if (!diffEditor) {
return
@@ -6,6 +6,7 @@
import { copyToClipboard, parseS3Object, roughSizeOfObject } from '$lib/utils'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { Button, Drawer, DrawerContent } from './common'
import {
ClipboardCopy,
@@ -176,9 +177,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let resultDownloadHref = $derived(
@@ -1016,9 +1019,7 @@
{#if largeObject}
<div class="text-xs text-emphasis"
>{#if resultApiPath && shouldDownloadViaClient()}
<button
onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}
>
<button onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}>
Download {filename ? '' : 'as JSON'}
</button>
{:else}
@@ -4,6 +4,7 @@
import Popover from './Popover.svelte'
import { copyToClipboard } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import type { DisplayResultUi } from './custom_ui'
import { createEventDispatcher } from 'svelte'
@@ -41,9 +42,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let downloadName = $derived(`${filename ?? 'result'}.json`)
@@ -13,6 +13,7 @@
import DropdownV2Inner from './DropdownV2Inner.svelte'
import { pointerDownOutside } from '$lib/utils'
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
import type { MenubarMenuElements } from '@melt-ui/svelte'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
@@ -40,7 +41,10 @@
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
// In customMenu mode the snippet receives the melt-ui `item` action
// store so consumers can wrap their own rows in <MenuItem> (or
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
maxHeight?: string | undefined
}
@@ -172,7 +176,7 @@
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{@render menu?.({ item, close })}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
+40 -2
View File
@@ -40,6 +40,7 @@
} from '$lib/stores'
import { editorConfig, registerWebviewPaste, updateOptions } from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash as randomHash } from '$lib/editorLangUtils'
import { workspaceStore } from '$lib/stores'
import {
@@ -147,6 +148,11 @@
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
// To execute preview scripts with the right worker group
customTag?: string
// Opt-in: reflect external `code` prop mutations back into Monaco (see
// the effect below). One-way `code={...}` callers that need live
// external updates — e.g. the inline flow rawscript — set this. Off by
// default so every other caller's behavior is unchanged.
syncExternalCode?: boolean
}
let {
@@ -178,7 +184,8 @@
enablePreprocessorSnippet = false,
rawAppRunnableKey = undefined,
preparedAssetsSqlQueries,
customTag
customTag,
syncExternalCode = false
}: Props = $props()
$effect.pre(() => {
@@ -1374,7 +1381,7 @@
$relativeLineNumbers
),
model,
fontSize: !small ? 13.5 : 12,
fontSize: small ? editorFontSize.small : editorFontSize.regular,
lineNumbersMinChars,
// overflowWidgetsDomNode: widgets,
tabSize: lang == 'python' ? 4 : 2,
@@ -1749,6 +1756,13 @@
let aiChatInlineWidget: AIChatInlineWidget | null = $state(null)
$effect(() => {
const fontSize = small ? editorFontSize.small : editorFontSize.regular
if (editor) {
editor.updateOptions({ fontSize })
}
})
let loadTimeout: number | undefined = undefined
onMount(async () => {
if (BROWSER) {
@@ -1829,6 +1843,30 @@
$effect(() => {
lang = scriptLangToEditorLang(scriptLang)
})
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
// the inline rawscript in the flow editor — otherwise mutate the prop
// without Monaco ever showing the change (the AI chat editing a flow
// module's content in a session is the motivating case). Gated off by
// default: Editor is sensitive and most callers either bind:code (and
// carry their own external-sync) or treat code as init-only, so a blanket
// setValue would risk clobbering them. The `getValue() !== code` guard
// keeps the caret intact when the change originated from typing inside
// Monaco (which round-trips code back via `$bindable`, re-firing this
// effect with `code === getValue()`).
let lastExternalCodeSync = code
$effect(() => {
if (!syncExternalCode) return
if (code === lastExternalCodeSync) return
lastExternalCodeSync = code
if (!editor) return
untrack(() => {
if (editor!.getValue() !== code) {
editor!.setValue(code ?? '')
}
})
})
$effect(() => {
filePath = computePath(path)
})
@@ -1,8 +1,8 @@
<!-- Used to avoid height jitter when loading monaco asynchronously -->
<script lang="ts">
import { untrack } from 'svelte'
import { getOS } from '$lib/utils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { MONACO_Y_PADDING } from './vscode'
type Props = {
@@ -22,9 +22,11 @@
lineNumbersOffset = 0,
class: className = '',
showNumbers = true,
fontSize = 13.5
fontSize: fontSizeProp
}: Props = $props()
let fontSize = $derived(fontSizeProp ?? editorFontSize.regular)
let yPadding = MONACO_Y_PADDING
// https://github.com/microsoft/vscode/blob/baa2dad3cdacd97ac02eff0604984faf1167ff1e/src/vs/editor/common/config/editorOptions.ts#L5421
@@ -46,7 +48,7 @@
const charWidth = 9 // try to match as closely as possible to monaco editor
const lineHeight = untrack(() => fontSize) * GOLDEN_LINE_HEIGHT_RATIO
let lineHeight = $derived(fontSize * GOLDEN_LINE_HEIGHT_RATIO)
let [clientWidth, clientHeight] = $state([0, 0])
let showHorizontalScrollbar = $derived(
@@ -6,13 +6,28 @@
interface Props {
beforeYaml: string
afterYaml: string
/** Side-by-side vs unified. Leave undefined to let
* FlowGraphDiffViewer show its own user-facing toggle (matches the
* pre-fork-diff-drawer behavior). */
inlineDiff?: boolean
/** Forwarded to FlowGraphDiffViewer — render an empty surface
* placeholder for the "before" / "after" pane when the item is
* added / removed. */
beforeMissing?: boolean
afterMissing?: boolean
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
inlineDiff = undefined,
beforeMissing = false,
afterMissing = false
}: Props = $props()
let diffMode: 'yaml' | 'graph' = $state('graph')
</script>
<div class="flex flex-col h-full min-h-[500px] gap-2">
<div class="flex flex-col h-full min-h-[500px]">
<Tabs bind:selected={diffMode}>
<Tab value="graph" label="Graph" />
<Tab value="yaml" label="YAML" />
@@ -30,6 +45,7 @@
defaultLang="yaml"
defaultOriginal={beforeYaml}
defaultModified={afterYaml}
{inlineDiff}
readOnly
/>
{/await}
@@ -37,7 +53,7 @@
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default {beforeYaml} {afterYaml} />
<Module.default {beforeYaml} {afterYaml} {beforeMissing} {afterMissing} {inlineDiff} />
{/await}
{/if}
</div>
@@ -2,12 +2,12 @@
import type { OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { Alert } from './common'
import { computeFlowModuleDiff } from './flows/flowDiff'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
@@ -15,13 +15,54 @@
interface Props {
beforeYaml: string
afterYaml: string
/** When true, render an empty surface placeholder for the "before"
* pane in side-by-side mode (use for added items where there's no
* prior flow to show). */
beforeMissing?: boolean
/** Same as `beforeMissing` but for the "after" pane (use for removed
* items). */
afterMissing?: boolean
/** Render the unified single-pane diff when true, side-by-side
* otherwise. When undefined, the component renders its own
* Unified / Side-by-side toggle in the corner (legacy behavior for
* the standalone comparison page). A narrow viewer still falls back
* to unified automatically. */
inlineDiff?: boolean | undefined
}
let { beforeYaml, afterYaml }: Props = $props()
let {
beforeYaml,
afterYaml,
beforeMissing = false,
afterMissing = false,
inlineDiff = undefined
}: Props = $props()
// Local toggle state, used only when no inlineDiff prop is supplied.
let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside')
const showLocalToggle = $derived(inlineDiff === undefined)
const effectiveInlineDiff = $derived(
inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified'
)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Track the content area's rendered height so unified-mode graphs can
// grow to fill the diff box (otherwise FlowGraphV2 sits at its
// content-fit height + small floor, leaving empty space below).
let contentAreaHeight = $state(0)
// Each FlowGraphV2 sizes itself to its own content (clamped to minHeight).
// In side-by-side mode we want both graphs to share the same height, so
// we track each side's reported height and feed back the max as minHeight
// to both. The width-graph then stays at its computed size; the shorter
// graph grows to match.
let beforeContentHeight = $state(0)
let afterContentHeight = $state(0)
const SHARED_MIN_HEIGHT = 400
const sharedMinHeight = $derived(
Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight)
)
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
@@ -29,7 +70,10 @@
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
function parseFlow(yaml: string, label: 'before' | 'after'): {
function parseFlow(
yaml: string,
label: 'before' | 'after'
): {
flow: OpenFlow | undefined
error: string | undefined
} {
@@ -49,14 +93,27 @@
}
}
let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before'))
let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after'))
// For added/removed items, the caller passes empty YAML and sets the
// corresponding *Missing flag. We swap in an empty OpenFlow stub on
// that side so the unified diff path still has something to compare
// against (every module on the present side becomes added / removed).
// The side-by-side rendering uses the flag directly to draw a
// placeholder pane instead.
const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } }
let beforeParsed = $derived.by(() =>
beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before')
)
let afterParsed = $derived.by(() =>
afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after')
)
let parseError = $derived(beforeParsed.error ?? afterParsed.error)
let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow)
let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow)
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Side-by-side unless the caller asked for unified, OR the viewer pane
// is too narrow to comfortably split (fallback to unified for legibility).
const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH)
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
@@ -72,14 +129,6 @@
sharedViewport = viewport
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
@@ -88,10 +137,12 @@
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#if showLocalToggle}
<!-- Legacy top banner — only holds the local Unified / Side-by-side
toggle when the parent doesn't pre-set inlineDiff. Zoom
controls live as an overlay below (same as the controlled path). -->
<div class="flex flex-row items-center justify-end m-2">
<ToggleButtonGroup bind:selected={localViewMode} noWFull>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
@@ -103,102 +154,131 @@
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{/if}
<!-- Main content area -->
<div class="flex-1 overflow-hidden relative" bind:clientHeight={contentAreaHeight}>
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
<!-- Shared zoom controls overlay the graph viewport. Uses xy-flow's
own `.svelte-flow__controls` / `.svelte-flow__controls-button`
classes so it inherits the same look as FlowGraphV2's built-in
controls (FlowGraphV2's global override gives the buttons
bg-surface + hover bg-surface-hover with border:0). Local
overrides bump the icon size from 12px to 16px and drop the
xy-flow default shadow. No fit-view button — recenter can't
sync across two graphs. -->
<div
class="svelte-flow__controls horizontal absolute top-[15px] right-[15px] z-10 rounded bg-surface border border-gray-200 dark:border-gray-700 overflow-hidden diff-zoom-controls"
>
<button
type="button"
aria-label="Zoom in"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
>
<Plus />
</button>
<button
type="button"
aria-label="Zoom out"
class="svelte-flow__controls-button"
onclick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
>
<Minus />
</button>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700 relative bg-surface-secondary {beforeMissing
? 'hatched-thin'
: ''}"
>
{#if beforeMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
Before <span class="italic">(no prior version)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (beforeContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
</div>
<div
class="flex flex-col h-full relative bg-surface-secondary {afterMissing
? 'hatched-thin'
: ''}"
>
{#if afterMissing}
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
After <span class="italic">(flow deleted)</span>
</span>
{:else}
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={sharedMinHeight}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
onHeight={(h) => (afterContentHeight = h)}
>
{#snippet leftHeader()}
<span class="text-2xs text-tertiary">After</span>
{/snippet}
</FlowGraphV2>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -219,7 +299,7 @@
editMode={false}
download={false}
scroll={false}
minHeight={400}
minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)}
triggerNode={false}
/>
</div>
@@ -231,3 +311,31 @@
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}
<style>
/* Thin diagonal hatch used as the empty-pane fill. */
.hatched-thin {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 6px,
rgba(128, 128, 128, 0.16) 6px,
rgba(128, 128, 128, 0.16) 7.5px
);
}
/* Shared zoom controls overlay: same xy-flow layout as the in-graph
controls (`.svelte-flow__controls.horizontal`) but with bigger Plus/Minus
glyphs (xy-flow caps svg at 12px by default) and no panel shadow. */
.diff-zoom-controls {
box-shadow: none !important;
}
.diff-zoom-controls :global(.svelte-flow__controls-button) {
width: 28px;
height: 28px;
}
.diff-zoom-controls :global(.svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
}
</style>
@@ -28,6 +28,7 @@
import ModuleStatus from './ModuleStatus.svelte'
import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import JobArgs from './JobArgs.svelte'
import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte'
import { deepEqual } from 'fast-equals'
@@ -1839,7 +1840,9 @@
style="min-height: {minTabHeight}px"
>
{#if !hideDownloadLogs && !isReplay && job?.id}
{@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`}
{@const logsApiPath = appendViewToken(
`/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`
)}
{@const logsName = `windmill_flow_logs_${job.id}.txt`}
<div class="flex justify-end p-1">
{#if shouldDownloadViaClient()}
@@ -1,19 +1,12 @@
<script lang="ts">
import { Markdown, type Plugin } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import rehypeRaw from 'rehype-raw'
import { rehypeGithubAlerts } from 'rehype-github-alerts'
import { Markdown } from 'svelte-exmarkdown'
import { markdownPlugins as plugins } from './markdownPlugins'
interface Props {
md: string
noPadding?: boolean
}
let { md, noPadding }: Props = $props()
const plugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeGithubAlerts] }
]
</script>
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
@@ -2,20 +2,19 @@
import { BROWSER } from 'esm-env'
import { editor as meditor, KeyMod, KeyCode } from 'monaco-editor'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { onDestroy, onMount } from 'svelte'
let divEl: HTMLDivElement | null = $state(null)
let editor: meditor.IStandaloneCodeEditor
interface Props {
code?: string;
class?: string;
code?: string
class?: string
}
let { code = '', class: className = '' }: Props = $props();
let { code = '', class: className = '' }: Props = $props()
async function loadMonaco() {
editor = meditor.create(divEl as HTMLDivElement, {
@@ -25,6 +24,7 @@
automaticLayout: true,
scrollBeyondLastLine: false,
lineNumbers: 'off',
fontSize: editorFontSize.regular,
minimap: { enabled: false }
})
@@ -43,6 +43,13 @@
}
})
$effect(() => {
const fontSize = editorFontSize.regular
if (editor) {
editor.updateOptions({ fontSize })
}
})
onDestroy(() => {
try {
editor && editor.dispose()
+4 -1
View File
@@ -14,6 +14,7 @@
import { deepEqual } from 'fast-equals'
import { isWindmillTooBigObject } from './job_args'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
interface Props {
id?: string | undefined
@@ -29,7 +30,9 @@
let jsonStr = $state('')
const argsDownloadName = 'windmill-args.json'
let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined)
let argsApiPath = $derived(
id && workspace ? appendViewToken(`/w/${workspace}/jobs_u/get_args/${id}`) : undefined
)
let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`)
function pythonCode() {
+18 -1
View File
@@ -15,6 +15,7 @@
type OpenFlow
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getViewToken } from '$lib/viewToken'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { getContext, onDestroy, tick, untrack } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
@@ -47,6 +48,9 @@
noLogs?: boolean
workspaceOverride?: string | undefined
notfound?: boolean
/** Status/body of the last load failure, so callers can distinguish e.g. a
* 403 (job exists but no access — offer a share link) from a 404. */
loadError?: { status?: number; message?: string } | undefined
allowConcurentRequests?: boolean
jobUpdateLastFetch?: Date | undefined
toastError?: boolean
@@ -65,6 +69,7 @@
allowConcurentRequests = false,
workspaceOverride = undefined,
notfound = $bindable(false),
loadError = $bindable(undefined),
jobUpdateLastFetch = $bindable(undefined),
toastError = false,
onlyResult = false,
@@ -600,9 +605,14 @@
}
}
notfound = false
loadError = undefined
} catch (err) {
const status = (err as any)?.status
loadError = { status, message: (err as any)?.body ?? (err as any)?.message }
errorIteration += 1
if (errorIteration == 5) {
// Auth failures won't resolve by retrying: surface them immediately so
// the caller can show the right message (e.g. 403 -> request a share link).
if (status === 403 || status === 404 || errorIteration == 5) {
notfound = true
job = undefined
clearCurrentId()
@@ -754,6 +764,13 @@
params.set('token', token.token)
}
// Share read link: SSE/EventSource can't set the X-View-Token header,
// so carry the token as a query param instead.
const viewToken = getViewToken()
if (viewToken) {
params.set('view_token', viewToken)
}
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}`
currentEventSource = new EventSource(sseUrl)
@@ -0,0 +1,101 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { classes } from '$lib/components/common/alert/model'
import { type Value } from '$lib/utils'
import { AlertCircle } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
interface Props {
/** Whether there are unsaved local changes relative to the deployed baseline. */
show: boolean
/** Deployed/backend baseline config (the "original" side of the diff). */
getDeployed: () => unknown
/** Current form config — the local autosave (the "current" side of the diff). */
getCurrent: () => unknown
/** Drop the local changes and reset the form to the deployed baseline. */
onDiscard: () => void | Promise<void>
/** When true (e.g. no write access), hide the Discard action. */
disabled?: boolean
/** Diff drawer title. */
title?: string
}
let {
show,
getDeployed,
getCurrent,
onDiscard,
disabled = false,
title = 'Deployed <> Local changes'
}: Props = $props()
let diffDrawer: DiffDrawer | undefined = $state()
function showDiff() {
const deployed = getDeployed()
if (deployed == null) return
// Snapshot both sides at click time. They are typically Svelte `$state`
// proxies (resource/variable's `initialStates[ws]` and the draft handle's
// cell); without snapshot the diff drawer would re-read them reactively
// and update as the user keeps typing behind it.
const original = $state.snapshot(deployed) as Value
const current = $state.snapshot(getCurrent()) as Value
diffDrawer?.openDrawer()
// Mirror the inline Discard's `disabled` gate inside the diff drawer —
// otherwise a read-only user could still trigger onDiscard via the
// drawer button even though we hid the banner's inline action.
diffDrawer?.setDiff({
mode: 'simple',
original,
current,
title,
button: disabled
? undefined
: {
text: 'Discard changes',
onClick: async () => {
await onDiscard()
diffDrawer?.closeDrawer()
}
}
})
}
</script>
<DiffDrawer bind:this={diffDrawer} />
{#if show}
<div
transition:slide|local={{ duration: 120 }}
class={twMerge(
'flex flex-row items-center justify-between gap-2 px-4 py-1',
classes.warning.bgClass,
'!border-0 !rounded-none'
)}
>
<div class="flex flex-row items-center gap-2 min-w-0">
<AlertCircle class={classes.warning.iconClass} size={16} />
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
You have unsaved changes
</span>
</div>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={showDiff}>Show diff</Button
>
{#if !disabled}
<Button
unifiedSize="sm"
variant="subtle"
btnClasses={classes.warning.titleClass}
on:click={onDiscard}>Discard</Button
>
{/if}
</div>
</div>
{/if}
+2 -1
View File
@@ -17,6 +17,7 @@
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
@@ -241,7 +242,7 @@
fetchedSkippedJobId = undefined
}
})
let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
let truncatedContent = $derived(
@@ -14,7 +14,6 @@
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
interface Props {
@@ -26,6 +25,14 @@
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
selected?: string | undefined
/** Notifies the parent drawer whether a local draft for the selected
* workspace diverges from the deployed baseline, so it can show the
* "unsaved changes" banner below its header. */
onDraftStateChange?: (hasDraft: boolean) => void
/** Notifies the parent drawer of write-access for the selected workspace,
* so it can hide the banner's Discard button in read-only mode (matches
* the trigger editors' `disabled={!can_write}` wiring). */
onCanWriteChange?: (canWrite: boolean) => void
}
let {
@@ -36,7 +43,9 @@
onChange,
defaultValues = undefined,
workspace = undefined,
selected: selectedProp = $bindable()
selected: selectedProp = $bindable(),
onDraftStateChange,
onCanWriteChange
}: Props = $props()
type ResourceState = {
@@ -189,6 +198,11 @@
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
// Banner is scoped to the selected workspace — the diff/discard only
// operate on it, so showing it for an unrelated dirty workspace would be
// misleading. The cross-workspace `otherDirty` alert below still covers
// that case.
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
@@ -219,9 +233,7 @@
const s: ResourceState = {
path: '',
description: '',
args: (defaultValues && Object.keys(defaultValues).length > 0
? defaultValues
: {}) as any,
args: (defaultValues && Object.keys(defaultValues).length > 0 ? defaultValues : {}) as any,
labels: undefined,
wsSpecific: false
}
@@ -276,11 +288,6 @@
{ workspace: ws }
)
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
@@ -329,6 +336,29 @@
canSave = anyDirty && dirtyValid && dirtyCanWrite
})
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
// (header + banner slot) lives in ResourceEditorDrawer, above this
// lazily-imported content, so the state is lifted up via these accessors.
$effect(() => {
onDraftStateChange?.(!!initialPath && selectedDirty)
})
$effect(() => {
onCanWriteChange?.(can_write)
})
export function localDraftDeployed(): ResourceState | undefined {
return selected ? initialStates[selected] : undefined
}
export function localDraftCurrent(): ResourceState | undefined {
return current
}
export function discardLocalDraft(): void {
if (!selected) return
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
workspace: selected
})
}
$effect(() => {
if (current)
// $state.snapshot deep-reads (so the effect re-runs on nested
@@ -6,6 +6,7 @@
import { Loader2, Save } from 'lucide-svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { workspaceStore } from '$lib/stores'
import LocalDraftBanner from './LocalDraftBanner.svelte'
let {
workspace = undefined,
@@ -17,7 +18,16 @@
let resource_type: string | undefined = $state(undefined)
let defaultValues: Record<string, any> | undefined = $state(undefined)
let resourceEditor: { save: () => void } | undefined = $state(undefined)
let resourceEditor:
| {
save: () => void
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
let canWriteSelected = $state(true)
let path: string | undefined = $state(undefined)
let selected: string | undefined = $state(undefined)
@@ -62,8 +72,19 @@
bind:this={resourceEditor}
bind:canSave
bind:selected
onDraftStateChange={(v) => (hasLocalDraft = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
<LocalDraftBanner
show={hasLocalDraft}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
disabled={!canWriteSelected}
/>
{/snippet}
{#snippet actions()}
{#if mode == 'edit' && path && effectiveWorkspace}
<WsSpecificVersions
@@ -55,6 +55,7 @@
Bug,
CheckCircle,
Code,
DiffIcon,
EllipsisVertical,
Plus,
Rocket,
@@ -81,7 +82,7 @@
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import LabelsInput from './LabelsInput.svelte'
@@ -101,7 +102,6 @@
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
import type { DiffDrawerI } from './diff_drawer'
import WorkerTagSelect from './WorkerTagSelect.svelte'
import type { ButtonType } from './common/button/model'
import DebounceLimit from './flows/DebounceLimit.svelte'
@@ -134,7 +134,9 @@
onSaveDraftError,
onSaveDraft,
onNavigate,
disableAi
disableAi,
initialTestPanelCollapsed = false,
initialPathChosen = false
}: ScriptBuilderProps = $props()
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
@@ -626,17 +628,23 @@
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
if (
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
// editor in place rather than navigating to the deployed item.
const stayHere =
stay ||
(script.auto_kind === 'lib' &&
script.kind !== 'preprocessor' &&
!isWorkflowAsCode(script.content, script.language))
) {
if (stayHere) {
// Re-pin parent_hash so the next deploy's conflict check is against
// the version we just wrote.
script.parent_hash = newHash
sendUserToast('Deployed')
} else {
onDeploy?.({ path: script.path, hash: newHash })
}
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
// a "Deploy & Stay here" or lib-script deploy.
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
} catch (error) {
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
@@ -793,66 +801,71 @@
loadingDraft = false
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Diff is exposed as a standalone
// top-bar button (rendered independently of the session pane), not here.
const inSessionPane = !!getContext('aiChatManager')
async function openDiffDrawer() {
if (!savedScript) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const deployed = deployedValue ?? savedScript
const current = { ...script, draft_triggers: currentDraftTriggers }
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
}
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
diffDrawer: DiffDrawerI | undefined
savedScript: NewScriptWithDraftAndDraftTriggers | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
}
},
{
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
...(!inSessionPane
? [
{
label: 'Edit in workspace fork',
label: 'Deploy & Stay here',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
handleEditScript(true)
}
}
]
: []),
...(customUi?.topBar?.diff !== false && savedScript && diffDrawer
? [
},
{
label: 'Show diff',
onClick: async () => {
if (!savedScript) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
const deployed = deployedValue ?? savedScript
const current = { ...script, draft_triggers: currentDraftTriggers }
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
label: 'Fork',
onClick: () => {
window.open(`/scripts/add?template=${initialPath}`)
}
}
},
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
? [
{
label: 'Edit in workspace fork',
onClick: () => {
window.open(buildForkEditUrl('script', initialPath))
}
}
]
: [])
]
: []),
...(!script.draft_only && script.kind === 'script' && !script.auto_kind
...(!inSessionPane &&
!script.draft_only &&
script.kind === 'script' &&
!script.auto_kind
? [
{
label: 'Exit & See details',
@@ -862,7 +875,7 @@
}
]
: []),
...(isWorkflowAsCode(script.content, script.language)
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
? [
{
label: 'Export as YAML/JSON',
@@ -875,7 +888,11 @@
]
: []
if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) {
if (
!inSessionPane &&
dropdownItems.length === 0 &&
isWorkflowAsCode(script.content, script.language)
) {
dropdownItems = [
{
label: 'Export as YAML/JSON',
@@ -901,7 +918,11 @@
}
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
// Seed "path is already chosen" so the summary→path auto-slug (which only
// runs for new scripts with initialPath == '') doesn't clobber a path the
// caller pre-assigned. The session preview opens AI-created scripts as new
// (empty initialPath) but with a path the AI already picked.
let dirtyPath = $state(initialPathChosen)
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state(
(() => {
@@ -2006,6 +2027,21 @@
</Button>
{/if}
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedScript || !diffDrawer}
iconOnly={compactTopbar}
title="Diff"
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
@@ -2019,8 +2055,10 @@
/>
{/snippet}
</DropdownV2>
{@render diffButton()}
{@render settingsButton()}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if $workerTags}
{#if $workerTags?.length ?? 0 > 0}
@@ -2051,7 +2089,7 @@
<DeployButton
loading={!fullyLoaded}
{loadingSave}
dropdownItems={computeDropdownItems(initialPath, savedScript, diffDrawer)}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
</div>
@@ -2091,6 +2129,7 @@
bind:assets={script.assets}
bind:modules={script.modules}
enablePreprocessorSnippet
{initialTestPanelCollapsed}
/>
</div>
{:else}
@@ -160,6 +160,11 @@
modules?: { [key: string]: ScriptModule } | null
editorBarRight?: import('svelte').Snippet
enablePreprocessorSnippet?: boolean
// When true the right-hand test/run pane mounts collapsed. The user
// can still expand it via `toggleTestPanel`. Defaults to false so the
// regular /scripts/edit route keeps its current open-by-default UX;
// the session preview opts in to save vertical real estate.
initialTestPanelCollapsed?: boolean
}
let {
@@ -193,7 +198,8 @@
assets = $bindable(),
modules = $bindable(undefined),
editorBarRight,
enablePreprocessorSnippet = false
enablePreprocessorSnippet = false,
initialTestPanelCollapsed = false
}: Props = $props()
let initialArgs = structuredClone($state.snapshot(args))
@@ -1360,8 +1366,11 @@
// dynamic minimum below — so when the editor shrinks, the displayed test
// pane grows to honor the new minimum without needing an effect. The code
// pane's size is purely derived from it (100 - test).
let rawTestPanelSize = $state(30)
let storedTestPanelSize = untrack(() => rawTestPanelSize)
// `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while
// keeping the "remembered" size at 30, so the user's first toggle expands
// the pane to a sensible width rather than 0.
let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30)))
let storedTestPanelSize = 30
const testPanelSize = $derived(
rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent)
)
@@ -14,6 +14,7 @@
import { BROWSER } from 'esm-env'
import { editorConfig, updateOptions } from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash } from '$lib/editorLangUtils'
// import {
@@ -298,7 +299,14 @@
}
})
let fontSize = $derived(small ? 12 : 13.5)
let fontSize = $derived(small ? editorFontSize.small : editorFontSize.regular)
$effect(() => {
const next = fontSize
if (editor) {
editor.updateOptions({ fontSize: next })
}
})
async function loadMonaco() {
setMonacoJsonOptions()
@@ -16,6 +16,7 @@
registerWebviewPaste,
updateOptions
} from '$lib/editorUtils'
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash } from '$lib/editorLangUtils'
import libStdContent from '$lib/es6.d.ts.txt?raw'
@@ -400,11 +401,13 @@
extraLib = '',
autoHeight = true,
fixedOverflowWidgets = true,
fontSize = 12,
fontSize,
loadAsync = false,
class: clazz = ''
}: Props = $props()
let effectiveFontSize = $derived(fontSize ?? editorFontSize.regular)
let yPadding = MONACO_Y_PADDING
if (typeof code != 'string') {
@@ -472,7 +475,7 @@
// lineNumbers: 'on',
lineDecorationsWidth: 0,
lineNumbersMinChars: 2,
fontSize,
fontSize: effectiveFontSize,
suggestOnTriggerCharacters: true,
renderLineHighlight: 'none',
lineNumbers: 'off',
@@ -688,6 +691,13 @@
$effect(() => {
mounted && extraLib && initialized && untrack(() => loadExtraLib())
})
$effect(() => {
const next = effectiveFontSize
if (editor) {
editor.updateOptions({ fontSize: next })
}
})
</script>
<EditorTheme />
@@ -696,7 +706,7 @@
class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)}
>
{#if !editor}
<FakeMonacoPlaceHolder autoheight showNumbers={false} {code} {fontSize} />
<FakeMonacoPlaceHolder autoheight showNumbers={false} {code} fontSize={effectiveFontSize} />
{/if}
<div
bind:this={divEl}
@@ -17,8 +17,8 @@
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
const dispatch = createEventDispatcher()
@@ -128,6 +128,11 @@
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
// Banner is scoped to the selected workspace — the diff/discard only
// operate on it, so showing it for an unrelated dirty workspace would be
// misleading. The cross-workspace `otherDirty` alert below still covers
// that case.
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
@@ -183,11 +188,6 @@
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('variable', p, s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
@@ -327,6 +327,20 @@
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
on:close={drawer?.closeDrawer}
>
{#snippet banner()}
<LocalDraftBanner
show={edit && selectedDirty}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
if (!selected) return
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
})
}}
disabled={!can_write}
/>
{/snippet}
<div class="flex flex-col gap-8">
{#if !can_write}
<Alert type="warning" title="Only read access">
@@ -75,7 +75,7 @@
disabled={edit && $userStore?.operator}
/>
{#if variable.is_secret}
<Alert type="warning" title="Audit log for each access">
<Alert type="info" title="Audit log for each access">
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
addition, any read of a secret variable generates an audit log whose operation name is:
variables.decrypt_secret
@@ -0,0 +1,162 @@
<!--
@component
Inline diff renderer for a single workspace item. Mirrors the per-kind
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
- everything else (apps, resources, variables, schedules, triggers…) →
a single Monaco YAML diff over the metadata
`inlineDiff` flips Monaco's `renderSideBySide` to false (unified view).
The component is content-sized — each Monaco block is sized to fit its
diff text (no internal scroll) using `lines * 19 + 24`; for the
Content+Metadata case we use the max of the two so switching tabs
doesn't reflow the parent.
-->
<script lang="ts">
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import FlowDiffViewer from './FlowDiffViewer.svelte'
import { Loader2 } from 'lucide-svelte'
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
import { scriptLangToEditorLang } from '$lib/scripts'
interface Props {
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
kind: string
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
* for "added" items (don't exist in the parent). */
originalRaw?: unknown
/** Raw value from `getItemValue(kind, path, forkWorkspace)`. Undefined
* for "removed" items (don't exist in the fork). */
currentRaw?: unknown
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
inlineDiff?: boolean
}
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
type Prepared = { lang?: string; content?: string; metadata: string }
function prepareValue(raw: unknown): Prepared {
if (!raw || typeof raw !== 'object') {
return { metadata: raw == null ? '' : String(raw) }
}
const cleaned = structuredClone(
cleanValueProperties(replaceFalseWithUndefined(raw as Record<string, unknown>))
)
const content = (cleaned as Record<string, unknown>)['content']
if (content !== undefined) {
delete (cleaned as Record<string, unknown>)['content']
}
const language = (raw as Record<string, unknown>).language
return {
lang:
typeof language === 'string'
? scriptLangToEditorLang(language as Parameters<typeof scriptLangToEditorLang>[0])
: undefined,
content: typeof content === 'string' ? content : undefined,
metadata: orderedYamlStringify(cleaned)
}
}
const original = $derived(prepareValue(originalRaw))
const current = $derived(prepareValue(currentRaw))
const hasContent = $derived(original.content !== undefined || current.content !== undefined)
// For added / removed flows, the missing side feeds an empty YAML so
// the YAML-mode editor shows the whole new (or removed) flow as a
// single-sided diff. FlowGraphDiffViewer uses the *Missing flag to
// swap in its own OpenFlow stub for parsing and to draw a placeholder
// pane in side-by-side mode.
const beforeFlowYaml = $derived(originalRaw == null ? '' : original.metadata)
const afterFlowYaml = $derived(currentRaw == null ? '' : current.metadata)
let contentTab: 'content' | 'metadata' = $state('content')
// Per-tab height: each Monaco block sizes to its own content. Switching
// tabs reflows the row, which is the expected tab behavior; we don't
// over-allocate to the larger tab the way the previous max() did.
const LINE_HEIGHT = 19
const EDITOR_CHROME = 24
function linesIn(s?: string): number {
return Math.max((s ?? '').split('\n').length, 1)
}
const contentHeight = $derived(
`${Math.max(linesIn(original.content), linesIn(current.content)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const metadataHeight = $derived(
`${Math.max(linesIn(original.metadata), linesIn(current.metadata)) * LINE_HEIGHT + EDITOR_CHROME}px`
)
const activeTabHeight = $derived(contentTab === 'content' ? contentHeight : metadataHeight)
</script>
{#if kind === 'flow'}
<div class="h-[600px]">
<FlowDiffViewer
beforeYaml={beforeFlowYaml}
afterYaml={afterFlowYaml}
beforeMissing={originalRaw == null}
afterMissing={currentRaw == null}
{inlineDiff}
/>
</div>
{:else if hasContent}
<div class="flex flex-col">
<Tabs bind:selected={contentTab}>
<Tab value="content" label="Content" />
<Tab value="metadata" label="Metadata" />
</Tabs>
<div style="height: {activeTabHeight}">
{#if contentTab === 'content'}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang={original.lang ?? current.lang}
defaultOriginal={original.content ?? ''}
defaultModified={current.content ?? ''}
{inlineDiff}
readOnly
/>
{/await}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
{/await}
{/if}
</div>
</div>
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
{:then Module}
<div style="height: {metadataHeight}">
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={original.metadata}
defaultModified={current.metadata}
{inlineDiff}
readOnly
/>
</div>
{/await}
{/if}
@@ -17,8 +17,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import { onMount, untrack } from 'svelte'
import { generateRandomString } from '$lib/utils'
import {
dirKey,
getCachedItems,
@@ -30,6 +32,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -62,7 +66,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let searchInput: TextInput | undefined = $state()
let pickerRoot: HTMLElement | undefined = $state()
const instanceId = crypto.randomUUID()
const instanceId = generateRandomString(8)
const listboxId = `pkr-list-${instanceId}`
const idFor = (key: string) => `pkr-${instanceId}-${key.replace(/[^a-zA-Z0-9-]/g, '_')}`
@@ -72,8 +76,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// Sibling-popover open: melt-ui's `openFocus` runs once during the close→open
// transition; the picker may not be mounted yet. Retry after settle.
// Also kicks off the initial scope's fetch — drill/goUp do the same from
// their respective branches, so `ensureLoaded` is always a callback
// reaction to user navigation, never a reactive consequence.
onMount(() => {
const t = setTimeout(focus, 50)
const initial = untrack(() => scope)
if (initial) {
if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(initial.kind)
}
return () => clearTimeout(t)
})
@@ -82,6 +94,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let scope = $state<Scope>(untrack(() => initialScope))
let filter = $state('')
/**
* Canonical entry point for changing the picker's scope. Triggers the
* fetch for the kind(s) the new scope needs at the same point in time.
* Replaces the older "react to `scope` change via `$effect`" wiring,
* which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the
* effect ended up subscribed to the signal it fills — every fetch
* result re-fired it. With explicit callbacks the fetch is tied to
* the user's action, never to a reactive consequence of that action.
*/
function setScope(next: Scope) {
scope = next
if (!next) return
if (next.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(next.kind)
}
/** Tracks whether the last user action was mouse movement (true) or
* keyboard nav (false). When false, row `mouseenter` events are ignored
* — prevents the cursor from stealing the keyboard-driven highlight as
@@ -90,10 +118,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* mounts under a stationary cursor doesn't clobber `initialHighlight`. */
let mouseActive = $state(false)
// Seed from cache so kinds already fetched in this session render on the
// first frame. Read once at mount: melt-ui mounts a fresh picker per
// popover open, so workspace changes are picked up at the next open
// without needing this seed to be reactive.
// Seed from the last fetched snapshot so kinds already fetched in this
// session render on the first frame. Each entry is replaced once
// `loadKind` returns fresh data — stale-while-revalidate, so deploys and
// AI-created drafts surface on the next open without explicit cache
// busting.
let loaded = $state<Partial<Record<Kind, Item[]>>>(
(() => {
if (!$workspaceStore) return {}
@@ -109,8 +138,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
async function ensureLoaded(kind: Kind) {
if (!$workspaceStore) return
if (loaded[kind]) return
loadingKind[kind] = true
// Always re-fetch. If we have nothing cached, show a spinner; if we do,
// keep displaying it and quietly swap to fresh data when it lands.
// `loaded[kind]` is read inside `untrack(...)` because this function is
// reachable from the search `$effect` below — without the untrack,
// that effect would subscribe to the signal `ensureLoaded` fills, and
// each `loaded[kind] = items` (proxy `set` notifies even when the ref
// is unchanged from cache) would refire it → runaway loop. Drill
// navigation goes through `setScope` directly so it isn't affected.
if (!untrack(() => loaded[kind])) loadingKind[kind] = true
try {
const items = await loadKind($workspaceStore, kind)
loaded[kind] = items
@@ -119,13 +155,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// Fetch the scope's kind on entry to a non-root level. The `'all'` scope
// needs every kind loaded since it merges items across them.
$effect(() => {
if (!scope) return
if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k)
else ensureLoaded(scope.kind)
})
// Chat tools and session editor previews write drafts through
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
// the picker so users can navigate to in-flight items that haven't been
// deployed yet. Filter to kinds the picker actually displays.
//
// Gated on the same dev flag as the rest of the sessions feature: without
// it there are no sessions, so the only UserDrafts present are the
// standalone editors' autosaves — surfacing those in the breadcrumb picker
// would be surprising (they'd appear as navigable items that 404 on the
// backend draft fetch). When the flag is off this is a no-op.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!isGlobalAiEnabled()) return []
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return listGlobalDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}))
}
// Searching is global → load every kind.
$effect(() => {
@@ -140,6 +194,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
leaves: Item[]
}
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
* scaffolded a script/flow/app via chat tools without the user saving
* yet — those drafts should be navigable from the picker. Existing items
* (same path) win to keep the backend's metadata (summary etc.). */
function withAiDrafts(items: Item[], k: Kind): Item[] {
const ai = aiDraftsForKind(k)
if (ai.length === 0) return items
const known = new Set(items.map((it) => it.path))
return items.concat(ai.filter((d) => !known.has(d.path)))
}
/** Inject the currently-edited item into a kind's list at its live path,
* dropping the saved entry when a draft rename is in progress. Other kinds
* pass through untouched. */
@@ -207,7 +272,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* cached. */
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
if (!kinds.includes(k)) return []
const items = withCurrent(list ?? [], k)
const items = withAiDrafts(withCurrent(list ?? [], k), k)
if (items.length === 0) return []
return buildTreeFromItems(items)
}
@@ -219,7 +284,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* one folder hierarchy. Each leaf still carries its real kind, so the row
* icon and `editPathFor` routing still work; folders contain a mix. */
const allTree = $derived.by(() => {
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
return merged.length === 0 ? [] : buildTreeFromItems(merged)
})
@@ -255,7 +320,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let allItems = $derived<SearchInput[]>(
kinds.flatMap((k) =>
withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` }))
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
...it,
_key: `${k}:${it.path}`
}))
)
)
@@ -383,9 +451,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
function drill(entry: Entry) {
if (entry.type === 'kind') {
scope = { kind: entry.kind }
setScope({ kind: entry.kind })
} else if (entry.type === 'dir') {
scope = { kind: entry.kind, dir: entry.node.fullPath }
setScope({ kind: entry.kind, dir: entry.node.fullPath })
} else {
pick(entry.item)
}
@@ -397,13 +465,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
// just left, so the user sees where they came from.
if (!scope.dir) {
const leaving = kindKey(scope.kind)
scope = undefined
setScope(undefined)
highlightedKey = leaving
return
}
const leaving = dirKey(scope.kind, scope.dir)
const parent = parentDirPath(scope.dir)
scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }
setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind })
highlightedKey = leaving
}
@@ -528,32 +596,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
{#snippet leafRow(it: Item, secondary: string, baseClass: string)}
{@const key = leafKey(it)}
{@const isHl = key === highlightedKey}
{@const isCur = isCurrent(it)}
<button
type="button"
<WorkspaceItemRow
kind={it.kind}
summary={it.summary}
{secondary}
highlighted={key === highlightedKey}
current={isCurrent(it)}
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
? 'bg-surface-hover'
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''}"
onmousedown={(e) => e.preventDefault()}
navKey={key}
{baseClass}
onclick={() => pick(it)}
onmouseenter={() => setHoverHighlight(key)}
>
<RowIcon kind={it.kind} size={12} />
<div class="min-w-0 flex-1">
{#if it.summary}
<div class="text-xs text-primary truncate">{it.summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
</button>
/>
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -0,0 +1,148 @@
<!--
@component
Visual row for a workspace item (script / flow / app / resource /
schedule / trigger / …). Matches the leaf-row layout used by
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
beneath, or just the mono path when there's no summary.
Pure presentation — the caller controls highlighting / current state via
props, supplies the onclick/onmouseenter handlers, and can pass an
`extras` snippet for right-side adornments (status dots, badges, …).
The button uses `onmousedown={(e) => e.preventDefault()}` so the click
doesn't steal focus from a sibling search input (matches the picker).
-->
<script module lang="ts">
import type { ComponentProps } from 'svelte'
import RowIconType from '$lib/components/common/table/RowIcon.svelte'
export type WorkspaceItemRowKind = ComponentProps<typeof RowIconType>['kind']
</script>
<script lang="ts">
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import type { Snippet } from 'svelte'
interface Props {
kind: WorkspaceItemRowKind
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
* Forwarded to RowIcon. */
triggerKind?: string
/** Optional summary text shown above the path. */
summary?: string
/** Mono path (or any secondary identifier). When summary is empty
* this is the only visible text. */
secondary: string
/** Highlighted via keyboard nav. Used for `aria-selected` +
* surface-hover background. */
highlighted?: boolean
/** "Currently editing this" — the picker uses this to grey out the
* active row and disable its click. */
current?: boolean
/** DOM id, used for `aria-activedescendant`. */
id?: string
/** Stamped on the element as `data-nav-key` so the parent can
* `pickerRoot.querySelector(...)` to scroll into view. */
navKey?: string
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
baseClass?: string
/** Extra left padding (px) for tree-view indentation. Adds to the
* default `px-3` horizontal padding. */
indent?: number
/** Title tooltip shown on hover; defaults to the secondary text. */
title?: string
/** When set, the row renders as an `<a href target="_blank">` link
* instead of a `<button>`. Used by callers that want native
* new-tab / cmd-click behaviour. `onclick` still forwards. */
href?: string
onclick?: () => void
onmouseenter?: () => void
/** Right-side adornments (status dot, badges, …). The `group` class
* is always applied to the root so the snippet can use
* `group-hover:*` utilities to reveal hover-only affordances. */
extras?: Snippet
}
let {
kind,
triggerKind,
summary,
secondary,
highlighted = false,
current = false,
id,
navKey,
baseClass = 'py-1.5',
indent = 0,
title,
href,
onclick,
onmouseenter,
extras
}: Props = $props()
const rootClass = $derived(
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
)
</script>
{#if href}
<a
{href}
target="_blank"
rel="noopener noreferrer"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</a>
{:else}
<button
type="button"
{id}
role="option"
aria-selected={highlighted}
aria-current={current ? 'true' : undefined}
data-nav-key={navKey}
title={title ?? secondary}
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
class={rootClass}
onmousedown={(e) => e.preventDefault()}
{onclick}
{onmouseenter}
>
<RowIcon {kind} {triggerKind} size={12} />
<div class="min-w-0 flex-1">
{#if summary}
<div class="text-xs text-primary truncate">{summary}</div>
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
{:else}
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
{/if}
</div>
{#if extras}
<div class="shrink-0 flex items-center gap-2">
{@render extras()}
</div>
{/if}
</button>
{/if}

Some files were not shown because too many files have changed in this diff Show More