Merge branch 'main' into change-50d49bad

This commit is contained in:
Diego Imbert
2026-09-04 12:06:20 +02:00
committed by GitHub
278 changed files with 15269 additions and 5108 deletions
+9 -13
View File
@@ -55,22 +55,18 @@
"mcp__claude_ai_Gmail__list_drafts"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/credentials.json)",
"Read(**/.secret*)",
"Read(**/.secrets*)",
"Read(**/*.secret)",
"Read(**/*.secrets)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(**/.env)",
"Edit(**/.env.*)"
"Edit(**/.env.*)",
"Edit(**/secrets/**)",
"Edit(**/*.pem)",
"Edit(**/*.key)",
"Edit(**/credentials.json)",
"Edit(**/.secret*)",
"Edit(**/.secrets*)",
"Edit(**/*.secret)",
"Edit(**/*.secrets)"
],
"ask": [
"Bash(rmdir:*)",
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
bun-version: 1.4.0
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.11.24"
+3 -1
View File
@@ -9,6 +9,7 @@ on:
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "frontend/src/lib/hubPaths.json"
- "backend/windmill-worker/src/result_processor.rs"
- "backend/windmill-api-workspaces/**"
- "cli/src/commands/sync/**"
@@ -22,6 +23,7 @@ on:
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "frontend/src/lib/hubPaths.json"
- "backend/windmill-worker/src/result_processor.rs"
- "backend/windmill-api-workspaces/**"
- "cli/src/commands/sync/**"
@@ -59,7 +61,7 @@ jobs:
echo "$CHANGED_FILES"
# Direct git sync file changes — always relevant.
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|frontend/src/lib/hubPaths\.json|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Relevant: direct git sync file changes"
exit 0
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.800.1"
".": "1.803.0"
}
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [1.803.0](https://github.com/windmill-labs/windmill/compare/v1.802.0...v1.803.0) (2026-09-03)
### Features
* expose request headers to scripts invoked via MCP ([#10903](https://github.com/windmill-labs/windmill/issues/10903)) ([e474e88](https://github.com/windmill-labs/windmill/commit/e474e8803ce2ff5c2df09a58dab51d45f5c922ca))
* reuse an existing workspace resource in the project import wizard ([#10935](https://github.com/windmill-labs/windmill/issues/10935)) ([582761e](https://github.com/windmill-labs/windmill/commit/582761e37c776e92dc1c6ebfee8c4efe7c35d822))
### Bug Fixes
* bump git sync hub scripts to cli 1.802.1, test the fork ui pull ([#10955](https://github.com/windmill-labs/windmill/issues/10955)) ([ca88009](https://github.com/windmill-labs/windmill/commit/ca8800959aa6a0017cc29bad187c9f49e0d13cc4))
* **cli:** make a sync push into a fork converge on schedules and inline names ([#10951](https://github.com/windmill-labs/windmill/issues/10951)) ([0f5a1db](https://github.com/windmill-labs/windmill/commit/0f5a1db2abba8df30a2f975f4498e269f13cf93d))
* fade the home Build with AI placeholder every 10s instead of typing it ([#10953](https://github.com/windmill-labs/windmill/issues/10953)) ([3d089b5](https://github.com/windmill-labs/windmill/commit/3d089b57344f5814086e6176301c5031dc519674))
* let operators use wmill.datatable() from within running jobs ([#10931](https://github.com/windmill-labs/windmill/issues/10931)) ([9b64a89](https://github.com/windmill-labs/windmill/commit/9b64a89cd46ae718d6c58fa12f925fa041fb1032))
## [1.802.0](https://github.com/windmill-labs/windmill/compare/v1.801.0...v1.802.0) (2026-09-02)
### Features
* add retention cleanup for the otel_traces table ([#10949](https://github.com/windmill-labs/windmill/issues/10949)) ([d472193](https://github.com/windmill-labs/windmill/commit/d472193e5bf5f6428e0096a402eb2c9299634fb2))
* open path links from chat messages in the session preview panel ([#10881](https://github.com/windmill-labs/windmill/issues/10881)) ([f10ac6c](https://github.com/windmill-labs/windmill/commit/f10ac6c2b3644fb16697e650efbc4f7cd3c6944c))
* restore owner and label filter chips on the homepage ([#10942](https://github.com/windmill-labs/windmill/issues/10942)) ([ccf8476](https://github.com/windmill-labs/windmill/commit/ccf84761dd664b9228dfe2f65867e8c32cd20c21))
* **sessions:** offer the item you came from when starting a new session ([#10940](https://github.com/windmill-labs/windmill/issues/10940)) ([d3747d6](https://github.com/windmill-labs/windmill/commit/d3747d62555ebcb09c78cfabcaa3b6177758d6ea))
* workspace setting to hide the AI assistant, agent steps unaffected ([#10941](https://github.com/windmill-labs/windmill/issues/10941)) ([fdd3b36](https://github.com/windmill-labs/windmill/commit/fdd3b36423344a2e1a464674179406581074e926))
### Bug Fixes
* apply object-storage test SSRF validation to all non-super-admins ([#10933](https://github.com/windmill-labs/windmill/issues/10933)) ([4fef119](https://github.com/windmill-labs/windmill/commit/4fef1195adaa9fa036a219884bd6c996460ca37f))
* connect to dev server instead of localhost ([#10912](https://github.com/windmill-labs/windmill/issues/10912)) ([337154b](https://github.com/windmill-labs/windmill/commit/337154b8304a5969f35216add627b5c1153c0f6c))
* preselect first row of AI agent and AI sandbox insert panes ([#10937](https://github.com/windmill-labs/windmill/issues/10937)) ([95b6bbd](https://github.com/windmill-labs/windmill/commit/95b6bbd46ada11d96a914ae5b0e92aba4dd02530))
* record supplied script lock hashes so importers can skip relocking ([#10915](https://github.com/windmill-labs/windmill/issues/10915)) ([17ba521](https://github.com/windmill-labs/windmill/commit/17ba521c352aec65a8270893752bbadd7f3d6eaa))
* sandbox script-controlled content types in result_to_response ([#10932](https://github.com/windmill-labs/windmill/issues/10932)) ([419741e](https://github.com/windmill-labs/windmill/commit/419741e5d226c67c51429094fb6ded9474afed99))
## [1.801.0](https://github.com/windmill-labs/windmill/compare/v1.800.1...v1.801.0) (2026-09-01)
### Features
* **ai-chat:** make reusable skills ai_skill resources you select per workspace ([#10914](https://github.com/windmill-labs/windmill/issues/10914)) ([cfcfe29](https://github.com/windmill-labs/windmill/commit/cfcfe298dd9ab50196bd64926ef78c4563f58c2c))
* **ai-sessions:** show a running session across tabs and reload finished turns ([#10916](https://github.com/windmill-labs/windmill/issues/10916)) ([816dc9d](https://github.com/windmill-labs/windmill/commit/816dc9dcd2c310e499d2d210a0abcd403469f29c))
* edit folders and groups in a drawer that saves once ([#10873](https://github.com/windmill-labs/windmill/issues/10873)) ([5d5ad4e](https://github.com/windmill-labs/windmill/commit/5d5ad4e8974e076ef53a26a5584e4209255a2248))
* make the home Build with AI composer dismissible, quiet the rest of the home page ([#10930](https://github.com/windmill-labs/windmill/issues/10930)) ([772fafe](https://github.com/windmill-labs/windmill/commit/772fafec8316a1e0c0e76b9a0737cc41d40a9a8c))
### Bug Fixes
* let a principal without a login account own a draft ([#10925](https://github.com/windmill-labs/windmill/issues/10925)) ([94af8d0](https://github.com/windmill-labs/windmill/commit/94af8d0fb5aceebe83936fd6761c6c1c02c75323))
* resolve chat path links against the session's operating workspace ([#10924](https://github.com/windmill-labs/windmill/issues/10924)) ([9074de2](https://github.com/windmill-labs/windmill/commit/9074de25ea730ca02653c9a2e2b8b99eda6f3137))
* tolerate string app_id in GHES app config deserialization ([#10923](https://github.com/windmill-labs/windmill/issues/10923)) ([af8ff38](https://github.com/windmill-labs/windmill/commit/af8ff3868748412cb658c803ebc8a71edc3cd8fb))
## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01)
+19
View File
@@ -36,3 +36,22 @@ _Avoid_: argument field, param
**Expression input**:
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
### Permissions
**Member**:
A user or group granted a role on a folder, a group, or an item's extra ACL. The list of them is
"Members (n)" everywhere it is shown, and one is added with "Add member".
_Avoid_: participant, collaborator, owner, ACL entry, permission (that names the concept, not the people)
**Role**:
The access level a member holds: viewer, writer or admin on a folder; member or admin on a group.
Viewers read, writers also edit, admins also manage the members. A group role of **manager**
manages the group without belonging to it — is a legacy state the UI shows and can leave, but
offers no way to enter.
_Avoid_: permission level, access level, rank
**Owner**:
Reserved for the path prefix that says where an item lives — `u/alice` or `f/team`. A folder's
`owners` column in the database is its admin members; call those admins, never owners, in the UI.
_Avoid_: using "owner" for a folder admin
+5 -5
View File
@@ -141,9 +141,9 @@ FROM ${DEBIAN_IMAGE}
ARG TARGETPLATFORM
ARG POWERSHELL_VERSION=7.5.0
ARG KUBECTL_VERSION=1.36.2
ARG HELM_VERSION=3.21.2
ARG HELM_VERSION=3.21.4
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
ARG GO_VERSION=1.26.0
ARG GO_VERSION=1.26.8
ARG APP=/usr/src/app
ARG WITH_POWERSHELL=true
ARG WITH_KUBECTL=true
@@ -250,8 +250,8 @@ RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_r
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
RUN apt-get -y update && apt-get install -y curl procps nodejs awscli && apt-get clean \
RUN curl -sL https://deb.nodesource.com/setup_24.x | bash -
RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs awscli && apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# go build is slower the first time it is ran, so we prewarm it in the build
@@ -299,7 +299,7 @@ RUN bun install -g windmill-cli \
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.30-cli-trixie /usr/local/bin/php /usr/bin/php
COPY --from=php:8.3.33-cli-trixie /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer
# add the docker client to call docker from a worker if enabled
+72 -5
View File
@@ -7,6 +7,7 @@ import type {
ListableApp,
ListableResource,
ListableVariable,
Resource,
Script
} from '../../../frontend/src/lib/gen'
import type {
@@ -81,6 +82,15 @@ export interface BenchmarkWorkspaceAiProvider {
isDefault?: boolean
}
/** A plain (non-AI) resource of the benchmark workspace, for cases about referencing a
* credential — passing one as a run argument, say. `value` is what `get_resource` returns. */
export interface BenchmarkWorkspaceResource {
path: string
resource_type: string
value?: Record<string, unknown>
description?: string
}
export interface BenchmarkWorkspaceJob {
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
id?: string
@@ -98,6 +108,7 @@ export interface BenchmarkWorkspaceRunnables {
apps?: BenchmarkWorkspaceApp[]
variables?: BenchmarkWorkspaceVariable[]
aiProviders?: BenchmarkWorkspaceAiProvider[]
resources?: BenchmarkWorkspaceResource[]
datatables?: BenchmarkDatatableSeed[]
jobs?: BenchmarkWorkspaceJob[]
}
@@ -284,15 +295,71 @@ export function listBenchmarkAiProviderResources(workspace: string): ListableRes
}))
}
/** The value of a seeded AI provider resource. Only the endpoint fields are modelled — a key is
* never needed, because no eval run calls the provider through this resource. */
/** Plain seeded resources of a benchmark workspace, shaped like `ResourceService.listResource`
* rows. Null when the workspace is not a benchmark one. */
export function listBenchmarkPlainResources(workspace: string): ListableResource[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
return null
}
return (runnables.resources ?? []).map((seed) => ({
workspace_id: workspace,
path: seed.path,
resource_type: seed.resource_type,
description: seed.description,
value: null,
is_oauth: false,
is_linked: false,
is_refreshed: false,
extra_perms: {},
edited_at: BENCHMARK_TIMESTAMP
}))
}
/** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both
* seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI
* providers too, and a case that lists resources and then reads one by path would otherwise get
* a row it cannot fetch. */
export function getBenchmarkResource(workspace: string, path: string): Resource | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
const seed = runnables?.resources?.find((entry) => entry.path === path)
if (seed) {
return {
workspace_id: workspace,
path: seed.path,
resource_type: seed.resource_type,
description: seed.description,
value: seed.value ?? {},
is_oauth: false,
extra_perms: {}
} as Resource
}
const provider = runnables?.aiProviders?.find((entry) => entry.path === path)
if (!provider) {
return null
}
return {
workspace_id: workspace,
path: provider.path,
resource_type: provider.kind,
value: getBenchmarkResourceValue(workspace, path) ?? {},
is_oauth: false,
extra_perms: {}
} as Resource
}
/** The value of a seeded resource. For an AI provider only the endpoint fields are modelled — a
* key is never needed, because no eval run calls the provider through this resource. */
export function getBenchmarkResourceValue(
workspace: string,
path: string
): Record<string, unknown> | null {
const seed = benchmarkWorkspaceRunnables
.get(workspace)
?.aiProviders?.find((entry) => entry.path === path)
const runnables = benchmarkWorkspaceRunnables.get(workspace)
const plain = runnables?.resources?.find((entry) => entry.path === path)
if (plain) {
return plain.value ?? {}
}
const seed = runnables?.aiProviders?.find((entry) => entry.path === path)
if (!seed) {
return null
}
@@ -70,7 +70,9 @@ vi.mock('$lib/gen', async () => {
getBenchmarkResourceValue,
getBenchmarkVariableByPath,
hasBenchmarkWorkspace,
getBenchmarkResource,
listBenchmarkAiProviderResources,
listBenchmarkPlainResources,
listBenchmarkApps,
listBenchmarkDatatables,
listBenchmarkDrafts,
@@ -359,18 +361,24 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkResourceValue(data.workspace, data.path))
: actual.ResourceService.existsResource(data),
// Only AI provider resources are modelled: they are what an AI agent step references.
listResource: async (data: { workspace: string; resourceType?: string }) => {
if (!hasBenchmarkWorkspace(data.workspace)) {
return actual.ResourceService.listResource(data)
}
const seeded = listBenchmarkAiProviderResources(data.workspace) ?? []
const seeded = [
...(listBenchmarkAiProviderResources(data.workspace) ?? []),
...(listBenchmarkPlainResources(data.workspace) ?? [])
]
const wanted = data.resourceType?.split(',')
return wanted ? seeded.filter((r) => wanted.includes(r.resource_type)) : seeded
},
getResource: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
const resource = getBenchmarkResource(data.workspace, data.path)
if (!resource) {
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
}
return resource
}
return actual.ResourceService.getResource(data)
},
+34
View File
@@ -2365,3 +2365,37 @@
- the step uses the workspace's anthropic resource f/evals/global/anthropic_main
- the model is the Opus one the user asked for, taken from the models that resource serves
- the diff flow input reaches the agent
# The failure this pins: passing a resource as `{"$res": "<path>"}` (or as a bare path), which
# reaches the script unresolved because the backend only substitutes a string value that itself
# starts with `$res:`. The mock preview echoes args back and reports success, so nothing in the
# loop corrects a wrong shape — the arg form is the whole test.
- id: global-run-arg-resource-reference
prompt: |-
Run `f/evals/global/github_repo_stats` against the `windmill-labs/windmill` repo, passing our
GitHub credentials at `f/evals/global/github_main` as its `gh_auth` input, and tell me whether
it went through.
initial: ai_evals/fixtures/frontend/global/initial/github_repo_stats_script.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- test_run_script
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
toolCallArgs:
# Exact: the mock never resolves the reference, so a near-miss path like
# `$res:f/evals/global/github_main_backup` would otherwise pass.
- tool: test_run_script
field: args.gh_auth
stringEqualsAnyOf:
- "$res:f/evals/global/github_main"
# The judge only sees drafts, and this case makes none — the deliverable is the shape of the
# run argument, checked deterministically above.
skipJudge: true
judgeChecklist:
- runs the existing script rather than rewriting it
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
+7
View File
@@ -160,6 +160,13 @@ export interface ToolCallArgumentRule {
field: string;
stringStartsWithAnyOf?: string[];
stringMustNotStartWithAnyOf?: string[];
/**
* Universal over calls: every recorded call to `tool` must carry `field` as
* exactly one of these strings. Use when a near-miss would still satisfy a
* prefix — a resource reference like `$res:f/a/b` shares its prefix with the
* wrong `$res:f/a/b_backup`, and the mock never resolves it to catch that.
*/
stringEqualsAnyOf?: string[];
/**
* Case-insensitive "contains", existential over calls: at least one recorded
* call to `tool` must have `field` containing one of these substrings. Other
+37
View File
@@ -228,6 +228,43 @@ describe("validateToolExpectations", () => {
});
});
// A resource reference shares its prefix with a wrong sibling path, and the mock
// never resolves it, so only exact matching separates the two.
it("rejects a resource reference whose path merely shares the prefix", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["test_run_script"],
toolCallDetails: [
{
name: "test_run_script",
arguments: { args: { gh_auth: "$res:f/evals/global/github_main_backup" } },
},
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [
{
tool: "test_run_script",
field: "args.gh_auth",
stringEqualsAnyOf: ["$res:f/evals/global/github_main"],
},
],
},
});
expect(checks).toContainEqual({
name: "test_run_script.args.gh_auth matches an accepted value",
passed: false,
details:
'accepted values: $res:f/evals/global/github_main; values: "$res:f/evals/global/github_main_backup"',
});
});
// The whole point of the same-call rule: the per-field rules are existential over
// calls, so two single-filter pages would satisfy them while never opening the
// combined view the case asks for.
+14
View File
@@ -278,6 +278,20 @@ export function validateToolExpectations(input: {
);
}
if (rule.stringEqualsAnyOf && rule.stringEqualsAnyOf.length > 0) {
const invalidValues = values.filter(
(value) =>
typeof value !== "string" || !rule.stringEqualsAnyOf!.includes(value)
);
checks.push(
check(
`${rule.tool}.${rule.field} matches an accepted value`,
invalidValues.length === 0,
`accepted values: ${rule.stringEqualsAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
)
);
}
if (rule.stringMustNotStartWithAnyOf && rule.stringMustNotStartWithAnyOf.length > 0) {
const invalidValues = values.filter(
(value) =>
@@ -0,0 +1,37 @@
{
"workspace": {
"resources": [
{
"path": "f/evals/global/github_main",
"resource_type": "github",
"description": "GitHub credentials",
"value": { "token": "$var:f/evals/global/github_token" }
}
],
"scripts": [
{
"path": "f/evals/global/github_repo_stats",
"summary": "Count open issues on a GitHub repository",
"description": "Reads the open issue count for a repository using GitHub credentials.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"gh_auth": {
"type": "object",
"format": "resource-github",
"description": "GitHub credentials"
},
"repo": {
"type": "string",
"description": "Repository in owner/name form"
}
},
"required": ["gh_auth", "repo"]
},
"content": "type Github = { token: string }\n\nexport async function main(gh_auth: Github, repo: string) {\n const res = await fetch(`https://api.github.com/repos/${repo}/issues?state=open`, {\n headers: { Authorization: `Bearer ${gh_auth.token}` }\n })\n const issues = await res.json()\n return { repo, open_issues: issues.length }\n}\n"
}
]
}
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "replacing!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
}
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n LEFT JOIN password p\n ON p.email = d.email\n AND p.super_admin = true\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)\n ORDER BY d.email NULLS LAST",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username?",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "draft_saved_at!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text"
]
},
"nullable": [
null,
false
]
},
"hash": "032b51ce97c2f31dc2aea8ddf64e6971818ea3b19b4d4866d16e1bf9f7f2ec6f"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "0a02678e3f062c8854226d6d5eb7e493c229d205048eeac78a7cbe328c689b88"
}
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
"query": "SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
"name": "lockfile_hash",
"type_info": "Int8"
}
],
"parameters": {
@@ -19,5 +19,5 @@
false
]
},
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
"hash": "1572b7348a05b7e357031f8d44b5bbee155569488352c10b334ce57d83ce1c0a"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trace_id FROM otel_traces ORDER BY trace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trace_id",
"type_info": "Bytea"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "16b174aaa944fd94458ce3108f0fec23514ae0419f77b632064b75473b5636c3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.path = dest.path\n AND src.typ = dest.typ)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "188d024a53b2ef37442412824f73ab5cd81242501d9e7a476698ea7acccd4aef"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM otel_traces WHERE ctid IN (\n SELECT ctid FROM otel_traces\n WHERE start_time_unix_nano < EXTRACT(\n EPOCH FROM now() - ($1::bigint::text || ' s')::interval\n )::bigint * 1000000000\n LIMIT $2\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "1b244f65ee6a2607ebc1c333d4359fbbf8be5a81276a3050a42770e4a5b5aa5e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "43ea7d0cf7f3c50ec1e79b4d2384d49b6c65bc442263f228912a24c1c5740cc8"
}
@@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH update_lock AS (\n UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3\n )\n INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n VALUES ($3, $4, $5)\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Varchar",
"Int8",
"Jsonb"
]
},
"nullable": []
},
"hash": "49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)\n VALUES ('admins', $1, $2, $3, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4bc43a5e1c95cb8989962aeb1393a50df05437a2f1909ad5d303e2c2b89a0668"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO otel_traces (trace_id, span_id, name, kind, start_time_unix_nano, end_time_unix_nano)\n VALUES ($1, $2, 'GET /', 3, $3, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea",
"Bytea",
"Int8"
]
},
"nullable": []
},
"hash": "62ed1fe52bc2c22d199101309cbbadb9842318c4c7a1d2526ac567ce41b7fbdc"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"describe": {
"columns": [
{
@@ -13,12 +13,14 @@
"Left": [
"Text",
"Jsonb",
"Text"
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae"
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT ON (path) path, lock FROM script\n WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL\n ORDER BY path, created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "lock",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "8da59f1ace46dc9830cb0fc5a640df68f552e38d4b587839e0e41285a2d55455"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "96aa1db2859c80d37ffecc2885442ae6d6d4b6731c3f62e7b85ae7a4f1b666a7"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft(workspace_id, path, typ, value, email) VALUES\n ('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),\n ('test-workspace', 'u/two/s', 'script', '{}'::json, 'test2@windmill.dev'),\n ('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "97691b88d43442e1f1984562372590428895b2bab67687c12efefe0b0d48881e"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
null
]
},
"hash": "9e503b65fe8bb1509f0d486829ce13ec9d93bb94192ddd85c3dacb9ff16cd032"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "WITH update_lock AS (\n UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3\n )\n INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n VALUES ($3, $4, $5)\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Varchar",
"Int8",
"Jsonb"
]
},
"nullable": []
},
"hash": "a0ec5048ddb7640b4407013ed45b7a545959c209c550e48bf108734f293e18e4"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "abdf62ef0e4eeb8c3213d2e8837e7032f710fe20dd272b7840c9bfbdb92554db"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n SELECT $1, * FROM UNNEST($2::text[], $3::bigint[])\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash\n WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"TextArray",
"Int8Array"
]
},
"nullable": []
},
"hash": "cc507843e00c83a42cc4a463999656ce9a8b0499d6b9282a3ecfae3164b17c2a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics",
"query": "VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics, otel_traces",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "f0070b36f7c4fc84dc9c23bb6c73d8ba80993a28b2c2e5df70968acf6d7cebe4"
"hash": "cfdd5ac1dfc7276fc37d49ddfe1b8880eaafb2d3fe71d75b676f1719e26f660f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "dd15827cbac128ec53cf03fa305e4cad34e540a2cc09c92e262491145a0de05a"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_fileset, format_extension FROM resource_type\n WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "format_extension",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM draft WHERE path = 'u/two/s'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "e3c4922e315b75951b5ea07cdfca4cfb32747b52dc62c9e3eccacf9c69e29b3a"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "instructions",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Text",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "ede523c0b0027f7bc1dacd3a5448031783fa7fbbbf86a291b6f8f8f875d45637"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM draft ORDER BY path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "f496f06f117f5c91104fc759df35b883070da938dea7c7975332afb876cc4691"
}
+99 -97
View File
@@ -970,9 +970,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -981,9 +981,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -5177,9 +5177,9 @@ dependencies = [
[[package]]
name = "frostem"
version = "1.20260821.3"
version = "1.20260821.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2e81eab151ba68484704bb3d21b4b4d2747314d3081fa86fa6c7300a4c42e2"
checksum = "481ace7f781f5ae54a5c0a6d6d8edb30adba737cfa1230fbd5632d63ba8dfd80"
[[package]]
name = "fs3"
@@ -7262,9 +7262,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.21"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
dependencies = [
"bitflags 2.13.1",
"libc",
@@ -7387,9 +7387,9 @@ dependencies = [
[[package]]
name = "lru"
version = "0.18.3"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
dependencies = [
"hashbrown 0.17.1",
]
@@ -7776,9 +7776,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@@ -7870,10 +7870,11 @@ dependencies = [
[[package]]
name = "mysql_async"
version = "0.37.0"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2"
checksum = "40d11da0e2d9fad4640c9f9198ee431c6d68444568f83ef1f10f3367270071e4"
dependencies = [
"arc-swap",
"bytes",
"crossbeam-queue",
"crossbeam-utils",
@@ -7882,7 +7883,7 @@ dependencies = [
"futures-sink",
"futures-util",
"keyed_priority_queue",
"lru 0.18.3",
"lru 0.18.4",
"mysql_common",
"native-tls",
"pem 3.0.6",
@@ -11753,9 +11754,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
dependencies = [
"serde",
]
@@ -12812,7 +12813,7 @@ dependencies = [
"itertools 0.14.0",
"levenshtein_automata",
"log",
"lru 0.18.3",
"lru 0.18.4",
"lz4_flex 0.14.0",
"measure_time",
"memmap2",
@@ -14746,7 +14747,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14831,7 +14832,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14864,7 +14865,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14877,7 +14878,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"argon2",
@@ -15017,7 +15018,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15040,7 +15041,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15057,7 +15058,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15083,7 +15084,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15093,7 +15094,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15110,7 +15111,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15132,7 +15133,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15155,7 +15156,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15171,7 +15172,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15193,7 +15194,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15214,7 +15215,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15228,7 +15229,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15263,7 +15264,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15288,7 +15289,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15316,7 +15317,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15338,7 +15339,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15358,7 +15359,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15396,7 +15397,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15424,7 +15425,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"lazy_static",
"serde",
@@ -15436,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15460,7 +15461,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15474,7 +15475,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15509,7 +15510,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15523,7 +15524,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15542,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15646,9 +15647,10 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"chrono",
"futures",
"itertools 0.14.0",
"lazy_static",
"serde",
@@ -15665,7 +15667,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"regex",
"serde",
@@ -15680,7 +15682,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15707,7 +15709,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"futures",
@@ -15724,7 +15726,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15740,7 +15742,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15761,7 +15763,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15792,7 +15794,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15817,7 +15819,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15851,7 +15853,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"futures",
@@ -15869,7 +15871,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15878,7 +15880,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15890,7 +15892,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15902,7 +15904,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15914,7 +15916,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15926,7 +15928,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15938,7 +15940,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15949,7 +15951,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15960,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15972,7 +15974,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15983,7 +15985,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16005,7 +16007,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16017,7 +16019,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16031,7 +16033,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16048,7 +16050,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16061,7 +16063,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde",
@@ -16073,7 +16075,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16093,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16107,7 +16109,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16123,7 +16125,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16137,7 +16139,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16176,7 +16178,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"const_format",
@@ -16216,7 +16218,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16227,7 +16229,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16262,7 +16264,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16286,7 +16288,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16319,7 +16321,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16348,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16381,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16399,7 +16401,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16433,7 +16435,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16469,7 +16471,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16492,7 +16494,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16516,7 +16518,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16540,7 +16542,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16575,7 +16577,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16603,7 +16605,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16628,7 +16630,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16647,7 +16649,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16764,7 +16766,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.800.1"
version = "1.803.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.800.1"
version = "1.803.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+2 -2
View File
@@ -85,7 +85,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
| EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream |
| EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation |
| EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts |
| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover |
| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers, script-controlled `wm_content_type`/`wm_headers` on `run_wait_result` and sync HTTP-route responses | stored user content → admin browser (same origin) | Admin session, account takeover |
| EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings |
| EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds |
| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS` now defaults to `true`; can still be overridden to `false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets |
@@ -106,7 +106,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
| T8 | Unauthenticated RCE via the Debugger WebSocket: `/ws_debug/*` exposed by the gateway/ingress with the debugger service as the auth boundary; signature gate was bypassable via `program`-mode launches (read+exec an arbitrary server-side file path, never signed) even with signing on, and the WS handshake had no Origin check (CSWSH) | remote_unauth | EP15 | Worker host, all assets | critical | possible | partially_mitigated | `program`-mode launches now rejected when `REQUIRE_SIGNED_DEBUG_REQUESTS` is on (signing covers every launch, not just inline `code`); shipped `docker-compose` now defaults `REQUIRE_SIGNED_DEBUG_REQUESTS=true`; opt-in `DEBUG_ALLOWED_ORIGINS` allowlist rejects cross-origin handshakes. Residual: code default is secure but operators can still set `=false`; origin allowlist is opt-in | GHSA-725h-99vx-9xr4 |
| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) |
| T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 |
| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 |
| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, S3 download content-type, or a script-chosen `text/html` content type on `run_wait_result` / sync HTTP-route responses (GET-reachable with the `SameSite=Lax` session cookie) | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads and on every `result_to_response` composite result (inserted after `wm_headers`; hop-by-hop names such as `Connection` rejected so a proxy cannot strip them) | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0, WIN-2471 |
| T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 |
| T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 |
| T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d |
+1 -1
View File
@@ -1 +1 @@
f2a31156ac08ecb02d89dbc66d72be58e9c877ff
d6297e6844dc2aab4745fce328e32ccab508969f
@@ -0,0 +1,13 @@
-- The up migration only ever added: `ai_skill` still holds every skill it copied,
-- so there is nothing to restore and nothing to delete. Removing the resources
-- would destroy any a user has since edited or created, and removing a folder
-- would take whatever else was put in it.
--
-- The seeded resource type goes. `created_by` only distinguishes this migration's
-- row from one a user created by hand: a hub sync updates the schema in place and
-- leaves `created_by` alone, so a synced-over row is still removed here and the
-- next sync puts it back.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_skill'
AND created_by = 'system';
@@ -0,0 +1,88 @@
-- AI chat skills move from the `ai_skill` table onto ordinary resources, so they
-- gain folder ACLs, version history, workspace export and git-sync. An `ai_skill`
-- resource holds the SKILL.md body in `value.content`; its description lives in
-- the resource's own `description` column and its name is the path basename.
--
-- Nothing here is destructive. `ai_skill` is left in place, unread, for a later
-- release to drop once operators have confirmed the copy. That is what lets every
-- step below skip on conflict rather than resolve one: a skipped row is still in
-- the table, so it is not lost, and the migration needs no record of what it did
-- in order to be reversible.
-- `format_extension` makes the resource editor render `value.content` as a plain
-- .md file. Seeded under 'admins' so every workspace sees it.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_skill',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Shared home matching the admin-only upload these skills had. A workspace that
-- already has a `skills` folder keeps it untouched, ACL and all: adopting one
-- would hand its own grants — possibly write for everyone — over a set of
-- instructions the assistant follows.
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
SELECT DISTINCT workspace_id, 'skills', 'Skills', ARRAY[]::TEXT[], '{"g/all": false}'::jsonb
FROM ai_skill
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Copied only where the destination is free and the folder matches the one above
-- exactly, owners included: a pre-existing folder carrying the same ACL but an
-- owner would hand that owner update and delete over skills the removed API let
-- only workspace admins touch. Anything else stays in `ai_skill` for an operator
-- to place deliberately.
--
-- What was actually inserted is recorded rather than inferred. Inferring it from
-- "is there an ai_skill resource at the destination" reports nothing when the
-- blocker is itself an ai_skill with different instructions — the one case where
-- the skipped skill is least likely to be noticed.
CREATE TEMP TABLE ai_skill_copied AS
WITH inserted AS (
INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at)
SELECT
s.workspace_id,
'f/skills/' || s.name,
jsonb_build_object('content', s.instructions),
s.description,
'ai_skill',
s.edited_by,
s.edited_at
FROM ai_skill s
JOIN folder f
ON f.workspace_id = s.workspace_id
AND f.name = 'skills'
AND f.extra_perms = '{"g/all": false}'::jsonb
AND cardinality(f.owners) = 0
ON CONFLICT (workspace_id, path) DO NOTHING
RETURNING workspace_id, path
)
SELECT workspace_id, path FROM inserted;
-- Anything not copied is still in `ai_skill`, but nothing reads that table any
-- more, so from the app's side the skill is missing until an operator places it.
-- Name them rather than leaving that to be discovered.
DO $$
DECLARE
leftover RECORD;
BEGIN
FOR leftover IN
SELECT s.workspace_id, s.name
FROM ai_skill s
WHERE NOT EXISTS (
SELECT 1 FROM ai_skill_copied c
WHERE c.workspace_id = s.workspace_id
AND c.path = 'f/skills/' || s.name
)
LOOP
RAISE WARNING 'ai_skill %/% was not copied to a resource (its destination or the f/skills folder is already taken); it remains in the ai_skill table',
leftover.workspace_id, leftover.name;
END LOOP;
END $$;
DROP TABLE ai_skill_copied;
@@ -0,0 +1,12 @@
-- Drafts owned by a principal with no login account cannot exist under the constraint; drop them
-- before restoring it.
DELETE FROM draft
WHERE email IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM password WHERE password.email = draft.email);
ALTER TABLE draft
ADD CONSTRAINT draft_password_fkey
FOREIGN KEY (email)
REFERENCES password(email)
ON DELETE CASCADE
ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- The delete and rename this cascaded are now explicit, at the sites that remove or rename an
-- account; `windmill_common::user_drafts::delete_drafts_of_email` carries the reasoning.
ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.800.1"
version = "1.803.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.800.1"
version = "1.803.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+80 -24
View File
@@ -7,14 +7,14 @@
*/
use anyhow::Context;
use monitor::{
load_base_url, load_otel, reload_critical_alerts_on_db_oversize,
reload_delete_logs_periodically_setting, reload_indexer_config,
reload_instance_python_version_setting, reload_maven_repos_setting,
flush_pending_log_files_to_object_store, load_base_url, load_otel,
reload_critical_alerts_on_db_oversize, reload_delete_logs_periodically_setting,
reload_indexer_config, reload_instance_python_version_setting, reload_maven_repos_setting,
reload_maven_settings_xml_setting, reload_no_default_maven_setting,
reload_nuget_config_setting, reload_powershell_repo_pat_setting,
reload_powershell_repo_url_setting, reload_ruby_repos_setting,
reload_timeout_wait_result_setting, reload_workspace_registries_setting,
flush_pending_log_files_to_object_store, send_logs_to_object_store, WORKERS_NAMES,
send_logs_to_object_store, WORKERS_NAMES,
};
use rand::Rng;
use sqlx::{Pool, Postgres};
@@ -53,9 +53,9 @@ use windmill_common::{
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING,
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING,
SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
@@ -140,12 +140,12 @@ use crate::monitor::{
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting,
reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting,
reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting,
reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting,
reload_sandbox_registry_auth_setting, reload_scim_token_setting,
reload_service_log_retention_secs_setting, reload_smtp_config,
reload_nsjail_tmpfs_size_setting, reload_otel_traces_retention_secs_setting,
reload_otel_tracing_proxy_setting, reload_pip_index_url_setting,
reload_retention_period_setting, reload_sandbox_image_cache_max_setting,
reload_sandbox_image_default_registry_setting, reload_sandbox_image_max_size_setting,
reload_sandbox_image_pull_policy_setting, reload_sandbox_registry_auth_setting,
reload_scim_token_setting, reload_service_log_retention_secs_setting, reload_smtp_config,
reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting,
reload_worker_config, MonitorIteration,
@@ -406,8 +406,12 @@ struct HubResourceTypeRaw {
pub schema: Option<String>,
pub app: String,
pub description: Option<String>,
/// Absent from hubs predating the column, and from caches written before it.
#[serde(default)]
pub format_extension: Option<String>,
}
/// Processed resource type with parsed schema
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct HubResourceType {
@@ -416,6 +420,18 @@ pub struct HubResourceType {
pub schema: Option<serde_json::Value>,
pub app: String,
pub description: Option<String>,
/// Doubly optional on purpose. A cache written before this column has no key at
/// all (`None`) and must leave the stored extension alone; one written since
/// always writes the key, so an explicit null (`Some(None)`) is the hub genuinely
/// dropping it and must clear. A single `Option` conflates the two, and picking
/// either meaning breaks the other — as does plain serde, which folds `null`
/// into the outer `None`, hence the wrapping deserializer.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option",
skip_serializing_if = "Option::is_none"
)]
pub format_extension: Option<Option<String>>,
}
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
@@ -462,6 +478,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect();
@@ -503,9 +520,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
tracing::info!("Found {} cached resource types", cached_types.len());
// Get existing resource types in admins workspace
let existing_types: Vec<(String, Option<serde_json::Value>, Option<String>)> = sqlx::query_as(
"SELECT name, schema, description FROM resource_type WHERE workspace_id = 'admins'",
// Get existing resource types in admins workspace. `format_extension` is part of
// the comparison below, so a type whose only change is gaining or losing it is
// not mistaken for unchanged; `is_fileset` decides whether it may take one.
let existing_types: Vec<(
String,
Option<serde_json::Value>,
Option<String>,
Option<String>,
bool,
)> = sqlx::query_as(
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
)
.fetch_all(db)
.await
@@ -513,19 +538,42 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
let existing_map: std::collections::HashMap<
String,
(Option<serde_json::Value>, Option<String>),
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
> = existing_types
.into_iter()
.map(|(name, schema, desc)| (name, (schema, desc)))
.map(|(name, schema, desc, format_extension, is_fileset)| {
(name, (schema, desc, format_extension, is_fileset))
})
.collect();
let mut synced_count = 0;
let mut skipped_count = 0;
for rt in cached_types {
// Check if resource type already exists with same schema and description
if let Some((existing_schema, existing_desc)) = existing_map.get(&rt.name) {
if existing_schema == &rt.schema && existing_desc == &rt.description {
let existing = existing_map.get(&rt.name);
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
// A fileset is a set of files, so it cannot also be one file. Create, update
// and the manual sync all reject the pair; this writer would otherwise
// persist it onto a same-named local fileset.
//
// A cache with no key at all leaves the stored value alone, so the target is
// what is already there — which is also what makes the comparison below
// agree with the write instead of re-upserting the row on every boot.
let format_extension = if is_fileset {
None
} else {
match &rt.format_extension {
Some(from_cache) => from_cache.clone(),
None => stored_extension.clone(),
}
};
if let Some((existing_schema, existing_desc, _, _)) = existing {
if existing_schema == &rt.schema
&& existing_desc == &rt.description
&& stored_extension == format_extension
{
skipped_count += 1;
continue;
}
@@ -533,14 +581,19 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
// Insert or update resource type
sqlx::query(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// `format_extension` is resolved above rather than coalesced here: a
// COALESCE could never clear one, so a hub that dropped an extension
// would leave the stale value behind forever.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
format_extension = EXCLUDED.format_extension, edited_at = now()",
)
.bind(&rt.name)
.bind(&rt.schema)
.bind(&rt.description)
.bind(&format_extension)
.execute(db)
.await
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
@@ -1960,6 +2013,9 @@ async fn process_notify_event(
SERVICE_LOG_RETENTION_SECS_SETTING => {
reload_service_log_retention_secs_setting(conn).await
}
OTEL_TRACES_RETENTION_SECS_SETTING => {
reload_otel_traces_retention_secs_setting(conn).await
}
RETENTION_PERIOD_SECS_OVERRIDES_SETTING => {
if let Err(e) = load_retention_period_overrides(db).await {
tracing::error!("Error loading per-workspace retention overrides: {e:#}");
+131 -9
View File
@@ -64,10 +64,11 @@ use windmill_common::{
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING,
SERVICE_LOG_RETENTION_SECS_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
@@ -97,10 +98,10 @@ use windmill_common::{
KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED,
CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
DEFAULT_SERVICE_LOG_RETENTION_SECS, HUB_BASE_URL, JOB_RETENTION_SECS,
JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED, METRICS_DEBUG_ENABLED,
METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED,
OTEL_TRACING_ENABLED, STORE_AUDIT_LOGS_S3,
DEFAULT_OTEL_TRACES_RETENTION_SECS, DEFAULT_SERVICE_LOG_RETENTION_SECS, HUB_BASE_URL,
JOB_RETENTION_SECS, JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED,
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, STORE_AUDIT_LOGS_S3,
};
use windmill_common::{
client::AuthedClient,
@@ -514,6 +515,15 @@ pub async fn initial_load(
Ordering::Relaxed,
)
});
pass.setting(OTEL_TRACES_RETENTION_SECS_SETTING, true, |v| async move {
windmill_common::set_otel_traces_retention_secs(parse_setting_value::<i64>(
v,
OTEL_TRACES_RETENTION_SECS_SETTING,
"OTEL_TRACES_RETENTION_SECS",
DEFAULT_OTEL_TRACES_RETENTION_SECS,
|x| x,
))
});
pass.setting(STORE_AUDIT_LOGS_S3_SETTING, true, |v| async move {
STORE_AUDIT_LOGS_S3.store(
parse_setting_value::<bool>(
@@ -1682,6 +1692,57 @@ const SERVICE_LOG_DELETE_BATCH: i64 = 2_000;
/// across ticks rather than inside one, the way the neighbouring sweeps already do.
const SERVICE_LOG_DELETE_MAX_BATCHES: usize = 10;
/// One span per HTTP request made from a job script, so the table grows far faster than the
/// job table it is keyed against; batched for the same reason the service log sweep is.
const OTEL_TRACES_DELETE_BATCH: i64 = 10_000;
const OTEL_TRACES_DELETE_MAX_BATCHES: usize = 10;
/// Delete HTTP request tracing spans older than `retention_secs`, returning how many went.
///
/// `retention_secs` is a parameter rather than a read of the process-wide setting so a test can
/// pin a window without writing state the other tests in this binary run against concurrently.
async fn delete_expired_otel_traces(db: &DB, retention_secs: i64) -> u64 {
// `start_time_unix_nano` is the proto field stored verbatim, so the cutoff is built in that
// unit rather than compared against `now()`. Truncating the epoch to whole seconds first
// keeps the multiplication inside `bigint`.
//
// Batched on `ctid`, not on the `(trace_id, span_id)` primary key: with the key the planner
// hashes the LIMITed subquery and Seq Scans the whole table to probe it, which at the size
// this table reaches is the cost the batching exists to avoid. `ctid` plans as a Tid Scan, so
// each batch touches only the rows it deletes. Safe because the subquery and the delete share
// one snapshot, and spans are never updated after insert.
let mut deleted = 0;
for _ in 0..OTEL_TRACES_DELETE_MAX_BATCHES {
let batch = sqlx::query!(
"DELETE FROM otel_traces WHERE ctid IN (
SELECT ctid FROM otel_traces
WHERE start_time_unix_nano < EXTRACT(
EPOCH FROM now() - ($1::bigint::text || ' s')::interval
)::bigint * 1000000000
LIMIT $2
)",
retention_secs,
OTEL_TRACES_DELETE_BATCH,
)
.execute(db)
.await;
match batch {
Ok(res) => {
deleted += res.rows_affected();
if (res.rows_affected() as i64) < OTEL_TRACES_DELETE_BATCH {
break;
}
}
Err(e) => {
tracing::error!("Error deleting expired otel trace spans: {:?}", e);
break;
}
}
}
deleted
}
pub async fn delete_expired_items(db: &DB) -> () {
let expired_tokens_r = sqlx::query_as!(
TokenRow,
@@ -1808,6 +1869,12 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
}
let deleted_spans =
delete_expired_otel_traces(db, windmill_common::otel_traces_retention_secs()).await;
if deleted_spans > 0 {
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
}
let audit_retention_days = audit_log_retention_days().await;
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
@@ -2927,6 +2994,21 @@ pub async fn reload_service_log_retention_secs_setting(conn: &Connection) {
}
}
pub async fn reload_otel_traces_retention_secs_setting(conn: &Connection) {
match load_setting_value::<i64>(
conn,
OTEL_TRACES_RETENTION_SECS_SETTING,
"OTEL_TRACES_RETENTION_SECS",
DEFAULT_OTEL_TRACES_RETENTION_SECS,
|x| x,
)
.await
{
Ok(v) => windmill_common::set_otel_traces_retention_secs(v),
Err(e) => tracing::error!("Error reloading otel traces retention period: {:?}", e),
}
}
pub async fn reload_audit_log_retention_days_setting(conn: &Connection) {
match load_setting_value::<i64>(
conn,
@@ -4839,7 +4921,7 @@ async fn poll_git_fork_branches(
}
async fn vacuuming_tables(db: &Pool<Postgres>) -> error::Result<()> {
sqlx::query!("VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics")
sqlx::query!("VACUUM v2_job, v2_job_completed, job_result_stream_v2, job_stats, job_logs, job_perms, concurrency_key, log_file, metrics, otel_traces")
.execute(db)
.await?;
Ok(())
@@ -6975,6 +7057,46 @@ mod zombie_worker_memory_pct_tests {
}
}
#[cfg(test)]
mod otel_traces_retention_tests {
use super::{delete_expired_otel_traces, DB};
async fn insert_span(db: &DB, id: u8, age_secs: i64) {
sqlx::query!(
"INSERT INTO otel_traces (trace_id, span_id, name, kind, start_time_unix_nano, end_time_unix_nano)
VALUES ($1, $2, 'GET /', 3, $3, $3)",
&[id; 16][..],
&[id; 8][..],
(chrono::Utc::now() - chrono::Duration::seconds(age_secs))
.timestamp_nanos_opt()
.unwrap(),
)
.execute(db)
.await
.unwrap();
}
/// The cutoff crosses two units: a retention configured in seconds against a column holding
/// nanoseconds. Getting that conversion wrong is silent in both directions — a window a
/// billion times too wide never deletes anything, one a billion times too narrow deletes
/// every span on the next tick — so pin it on either side of the boundary.
#[sqlx::test(migrations = "./migrations")]
async fn deletes_only_spans_past_the_window(db: DB) -> anyhow::Result<()> {
let day = 60 * 60 * 24;
insert_span(&db, 1, 60).await;
insert_span(&db, 2, 6 * day).await;
insert_span(&db, 3, 8 * day).await;
assert_eq!(delete_expired_otel_traces(&db, 7 * day).await, 1);
let kept = sqlx::query_scalar!("SELECT trace_id FROM otel_traces ORDER BY trace_id")
.fetch_all(&db)
.await?;
assert_eq!(kept, vec![vec![1u8; 16], vec![2u8; 16]]);
Ok(())
}
}
#[cfg(test)]
mod log_file_listing_tests {
use super::{rotated_log_files, sorted_log_files};
+11 -1
View File
@@ -2,7 +2,9 @@
-- Layered on top of `base` (which provides test-workspace and the non-operator
-- `test-user-2`/SECRET_TOKEN_2). Adds an Operator member so we can assert that
-- Operators cannot reach the arbitrary-code inline preview path
-- (`POST /jobs/run_inline/preview`).
-- (`POST /jobs/run_inline/preview`) with their own token, plus two deployed script
-- jobs of the operator: one running, so we can assert that its WM_TOKEN can, and
-- one queued but not yet pulled, so we can assert that "queued" is not enough.
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('operator@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Operator User');
@@ -12,3 +14,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, operator, role) VALUES
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES
(encode(sha256('OPERATOR_TOKEN'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN', 'operator@windmill.dev', 'operator token', false);
INSERT INTO v2_job(id, workspace_id, kind, runnable_path, created_by, permissioned_as, permissioned_as_email) VALUES
('2aa0c0de-0000-4000-8000-000000000001', 'test-workspace', 'script', 'u/test-user/deployed', 'operator-user', 'u/operator-user', 'operator@windmill.dev'),
('2aa0c0de-0000-4000-8000-000000000002', 'test-workspace', 'script', 'u/test-user/deployed', 'operator-user', 'u/operator-user', 'operator@windmill.dev');
INSERT INTO v2_job_queue(id, workspace_id, scheduled_for, running) VALUES
('2aa0c0de-0000-4000-8000-000000000001', 'test-workspace', now(), true),
('2aa0c0de-0000-4000-8000-000000000002', 'test-workspace', now(), false);
+184 -15
View File
@@ -9,16 +9,33 @@
//! was the incomplete-fix residual of CVE-2026-22683, whose v1.615.0 patch only
//! covered the entity-CRUD endpoints and left this direct inline-exec sink open.
//!
//! The guard on both routes has one exemption: `wmill.datatable()` called from
//! inside a job the operator is running. Operators can only run deployed code,
//! so a request the job's WM_TOKEN authenticates comes from code a non-operator
//! authored, and the exemption is limited to the request shape the helper sends
//! (PostgreSQL against a `datatable://` database) so a leaked WM_TOKEN cannot
//! be replayed to run anything else.
//!
//! This test pins down:
//! - an Operator is rejected by the operator guard (the core fix; pre-fix this
//! reached the inline executor instead of returning 401), and
//! - an Operator's own token is rejected by the operator guard (the core fix;
//! pre-fix this reached the inline executor instead of returning 401),
//! - a regular non-operator passes the guard (the fix must not over-block the
//! legitimate inline preview flow): in the test harness the worker inline
//! utils are not registered, so a caller past the guard gets the distinct
//! "worker inline functions" error rather than the operator rejection.
//! "worker inline functions" error rather than the operator rejection,
//! - an Operator's job token passes the guard for a datatable query while its
//! job is running, on the inline route and on the `/jobs/run/preview`
//! fallback the SDKs use when the worker has no internal server,
//! - the same token is rejected for any other payload (in-process DuckDB, or a
//! `-- database` directive redirecting the query, whether written literally or
//! reached through a `WM_INTERNAL_DB` marker) and for a deferred run,
//! - an Operator's job token for a job that is not running, whether finished or
//! merely queued, is rejected.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::auth::create_jwt_token;
use windmill_common::db::Authed;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
@@ -38,11 +55,65 @@ fn inline_preview_body() -> serde_json::Value {
})
}
/// The request `wmill.datatable("main")` sends: PostgreSQL against `datatable://main`.
fn datatable_query_body() -> serde_json::Value {
json!({
"language": "postgresql",
"content": "SELECT 1 AS x;",
"args": { "database": "datatable://main" }
})
}
/// Mint the WM_TOKEN a job hands its own code: an internally-signed job JWT
/// (note the `job_id` claim) for the fixture's operator, exactly as the worker
/// issues it when the operator runs a deployed script.
async fn operator_job_token(job_id: uuid::Uuid) -> String {
let authed = Authed {
email: "operator@windmill.dev".to_string(),
username: "operator-user".to_string(),
is_admin: false,
is_operator: true,
groups: vec![],
folders: vec![],
scopes: None,
token_prefix: None,
};
create_jwt_token(
authed,
"test-workspace",
3600,
Some(job_id),
Some("ephemeral-script".to_string()),
None,
None,
)
.await
.expect("mint operator job token")
}
const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs";
/// The fixture's deployed-script jobs of the operator: one running, one queued.
const RUNNING_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000001";
const QUEUED_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000002";
async fn post(url: &str, token: &str, body: &serde_json::Value) -> (u16, String) {
let resp = authed(client().post(url), token)
.json(body)
.send()
.await
.expect("request");
let status = resp.status().as_u16();
let body = resp.text().await.expect("body");
(status, body)
}
#[sqlx::test(fixtures("base", "inline_preview_auth"))]
async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The server decodes WM_TOKENs with the same in-process JWT secret, so
// setting it once lets us mint valid ones below.
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
@@ -51,12 +122,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
// 1. CORE REGRESSION: an Operator must be rejected by the operator guard.
// Pre-fix this fell through to the inline executor (arbitrary code
// execution); post-fix it returns 401 with the operator guard message.
let resp = authed(client().post(&url), "OPERATOR_TOKEN")
.json(&inline_preview_body())
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
let (status, body) = post(&url, "OPERATOR_TOKEN", &inline_preview_body()).await;
assert_eq!(
status, 401,
"Operator must be rejected from inline preview (got {status}): {body}"
@@ -71,12 +137,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
// the worker inline utils, so the request proceeds past the guard and
// fails later with the distinct "worker inline functions" error — proving
// the operator guard did not reject it.
let resp = authed(client().post(&url), "SECRET_TOKEN_2")
.json(&inline_preview_body())
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
let (status, body) = post(&url, "SECRET_TOKEN_2", &inline_preview_body()).await;
assert_ne!(
status, 401,
"non-operator must not be blocked by the operator guard (got {status}): {body}"
@@ -86,5 +147,113 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
"non-operator must not hit the operator guard, got: {body}"
);
// 3. The WM_TOKEN of a deployed-script job the Operator is running passes the
// guard for a datatable query: this is `wmill.datatable()` called from
// inside that job. As in 2, the harness then fails with the "worker inline
// functions" error.
let running_job_token =
operator_job_token(uuid::Uuid::parse_str(RUNNING_JOB_ID).unwrap()).await;
let (status, body) = post(&url, &running_job_token, &datatable_query_body()).await;
assert_ne!(
status, 401,
"operator job token of a running job must pass the guard for a datatable query (got {status}): {body}"
);
assert!(
!body.contains(OPERATOR_GUARD_MSG),
"operator job token of a running job must not hit the operator guard, got: {body}"
);
// 4. The same token is rejected for any other payload: the exemption covers
// the datatable request shape only, never in-process DuckDB, and never a
// `-- database` directive, which the executor honors over `args.database`.
let mut redirected = datatable_query_body();
redirected["content"] = json!("-- database u/test-user/other_db\nSELECT 1 AS x;");
let mut to_s3 = datatable_query_body();
to_s3["content"] = json!("-- s3\nSELECT 1 AS x;");
let mut resource_db = datatable_query_body();
resource_db["args"]["database"] = json!("$res:u/test-user/other_db");
// A marker is a single line the directive regexes cannot match; the directive only
// appears once the executor expands it, so the guard must check the expansion.
let mut marker = datatable_query_body();
marker["content"] = json!(concat!(
r#"-- WM_INTERNAL_DB_SELECT {"table":"t","columnDefs":[{"field":"id","datatype":"int4"}],"#,
r#""whereClause":"true\n-- database u/test-user/other_db\n AND true"}"#
));
for (label, payload) in [
("DuckDB", inline_preview_body()),
("database directive", redirected),
("s3 directive", to_s3),
("resource database", resource_db),
("marker-expanded database directive", marker),
] {
let (status, body) = post(&url, &running_job_token, &payload).await;
assert_eq!(
status, 401,
"operator job token must be rejected for a {label} payload (got {status}): {body}"
);
assert!(
body.contains(OPERATOR_GUARD_MSG),
"rejection for a {label} payload must be the operator guard, got: {body}"
);
}
// 5. An Operator's job token whose job is not running is rejected like the
// operator's own token, whether the job is over (no queue row) or merely
// queued: a WM_TOKEN that leaked through logs cannot be replayed once the
// job is over.
for (label, job_id) in [
("finished", uuid::Uuid::new_v4()),
("queued", uuid::Uuid::parse_str(QUEUED_JOB_ID).unwrap()),
] {
let token = operator_job_token(job_id).await;
let (status, body) = post(&url, &token, &datatable_query_body()).await;
assert_eq!(
status, 401,
"operator job token of a {label} job must be rejected (got {status}): {body}"
);
assert!(
body.contains(OPERATOR_GUARD_MSG),
"rejection for a {label} job must be the operator guard, got: {body}"
);
}
// 6. The SDKs fall back to `/jobs/run/preview` when the worker has no internal
// server (agent workers). The same exemption applies there: the running
// job's token queues the datatable query (201 with the job id), the
// operator's own token is still refused.
let fallback_url = format!("http://localhost:{port}/api/w/test-workspace/jobs/run/preview");
let (status, body) = post(&fallback_url, &running_job_token, &datatable_query_body()).await;
assert_eq!(
status, 201,
"operator job token of a running job must queue a datatable preview (got {status}): {body}"
);
let (status, body) = post(&fallback_url, "OPERATOR_TOKEN", &datatable_query_body()).await;
assert_eq!(
status, 401,
"Operator must be rejected from the preview fallback (got {status}): {body}"
);
assert!(
body.contains(OPERATOR_GUARD_MSG),
"rejection must be the operator guard, got: {body}"
);
// 7. A deferred run on the fallback would outlive the running job the
// exemption keys off, so the running job's token cannot schedule one.
for deferral in [
"scheduled_in_secs=86400",
"scheduled_for=2099-01-01T00:00:00Z",
] {
let deferred_url = format!("{fallback_url}?{deferral}");
let (status, body) = post(&deferred_url, &running_job_token, &datatable_query_body()).await;
assert_eq!(
status, 401,
"operator job token must not schedule a deferred preview with {deferral} (got {status}): {body}"
);
assert!(
body.contains(OPERATOR_GUARD_MSG),
"rejection for {deferral} must be the operator guard, got: {body}"
);
}
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
//! `POST /api/settings/test_object_storage_config` runs the probe on the API server and reflects the
//! upstream response, so every non-super-admin must be rejected for private/loopback endpoints and
//! the Filesystem backend on every deployment (`CLOUD_HOSTED` is unset here), while a super admin's
//! Filesystem probe still round-trips. Requires the `parquet` feature, like the route.
#![cfg(feature = "parquet")]
use serde_json::json;
use sqlx::{Pool, Postgres};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use windmill_test_utils::*;
const SUPER_ADMIN_TOKEN: &str = "SECRET_TOKEN";
const USER_TOKEN: &str = "SECRET_TOKEN_2";
async fn test_object_storage(
url: &str,
token: &str,
body: serde_json::Value,
) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(url)
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
#[sqlx::test(fixtures("base"))]
async fn object_storage_test_is_restricted_for_non_super_admins_off_cloud(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let url = format!(
"http://localhost:{}/api/settings/test_object_storage_config",
server.addr.port()
);
// A loopback "S3 endpoint" standing in for an internal service: the probe must be rejected
// before the server opens a connection to it.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let internal_port = listener.local_addr()?.port();
let connected = Arc::new(AtomicBool::new(false));
tokio::spawn({
let connected = connected.clone();
async move {
while listener.accept().await.is_ok() {
connected.store(true, Ordering::SeqCst);
}
}
});
let internal_s3 = json!({
"type": "S3",
"bucket": "bucket",
"region": "us-east-1",
"access_key": "key",
"secret_key": "secret",
"endpoint": format!("http://127.0.0.1:{internal_port}"),
"allow_http": true,
"path_style": true,
});
let (status, body) = test_object_storage(&url, USER_TOKEN, internal_s3).await?;
assert_eq!(
status, 401,
"non-super-admin must be rejected for a loopback endpoint (got {status}): {body}"
);
assert!(
body.contains("requires a super admin"),
"unexpected rejection: {body}"
);
assert!(
!connected.load(Ordering::SeqCst),
"the server must not connect to the rejected endpoint"
);
let tmp = tempfile::tempdir()?;
let filesystem = json!({ "type": "Filesystem", "root_path": tmp.path().to_str().unwrap() });
let (status, body) = test_object_storage(&url, USER_TOKEN, filesystem.clone()).await?;
assert_eq!(
status, 401,
"non-super-admin must be rejected for a Filesystem backend (got {status}): {body}"
);
assert!(
body.contains("requires a super admin"),
"unexpected rejection: {body}"
);
// Super admins keep the unrestricted path.
let (status, body) = test_object_storage(&url, SUPER_ADMIN_TOKEN, filesystem).await?;
assert_eq!(
status, 200,
"super admin must be able to test a Filesystem backend (got {status}): {body}"
);
Ok(())
}
+415
View File
@@ -0,0 +1,415 @@
use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use windmill_api_client::types::NewScript;
use windmill_common::scripts::{deploy_relocked_version, fetch_script_for_update};
use windmill_test_utils::*;
const W: &str = "test-workspace";
const A: &str = r#"export async function main() { return "a" }"#;
const A_COMMENTED: &str = r#"// same dependencies, different content
export async function main() { return "a" }"#;
const A_WITH_LODASH: &str = r#"import _ from "lodash@4.17.21";
export async function main() { return _.trim(" a ") }"#;
const B: &str = r#"import { main as a } from "/f/rel/a.ts";
export async function main() { return "b" + (await a()) }"#;
const C: &str = r#"import { main as b } from "/f/rel/b.ts";
export async function main() { return "c" + (await b()) }"#;
fn bun_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
NewScript {
draft_only: None,
content: content.into(),
language: windmill_api_client::types::ScriptLang::Bun,
lock: None,
parent_hash,
path: path.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
envs: vec![],
is_template: None,
kind: None,
summary: "".to_string(),
tag: None,
schema: std::collections::HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
#[derive(sqlx::FromRow, Debug)]
struct Version {
hash: i64,
archived: bool,
lock: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
/// Every version of `path`, oldest first.
async fn versions(db: &Pool<Postgres>, path: &str) -> Vec<Version> {
sqlx::query_as(
"SELECT hash, archived, lock, created_at FROM script
WHERE workspace_id = $1 AND path = $2 ORDER BY created_at",
)
.bind(W)
.bind(path)
.fetch_all(db)
.await
.unwrap()
}
fn live(versions: &[Version]) -> &Version {
versions.iter().rev().find(|v| !v.archived).unwrap()
}
/// `(path, status, logs)` of every dependency job created after `since`, in completion order.
async fn dependency_jobs_since(
db: &Pool<Postgres>,
since: chrono::DateTime<chrono::Utc>,
) -> Vec<(String, String, String)> {
sqlx::query_as(
"SELECT j.runnable_path, c.status::text, COALESCE(l.logs, '') FROM v2_job_completed c
JOIN v2_job j ON j.id = c.id
LEFT JOIN job_logs l ON l.job_id = c.id
WHERE j.kind = 'dependencies' AND j.created_at > $1
ORDER BY c.started_at",
)
.bind(since)
.fetch_all(db)
.await
.unwrap()
}
async fn wait_for_jobs(
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
count: usize,
) {
for _ in 0..count {
completed.next().await;
}
// Then let anything else that was queued run out, so a job the assertions say must not
// exist would have shown up here.
while let Ok(Some(_)) =
tokio::time::timeout(std::time::Duration::from_secs(2), completed.next()).await
{}
}
/// A redeploy of an imported script whose dependencies did not move relocks its importer,
/// and that relock must deploy nothing: no new version, and no dependency job for the
/// importer's own importers. A redeploy that does change the dependencies still walks the
/// whole chain with a new version at each step.
#[sqlx::test(fixtures("base"))]
async fn relative_import_relock_deploys_only_when_the_lock_changed(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(
&db,
async {
// One at a time: each deploy's dependency job records the importer's edges, and an
// importer whose edges are recorded is what a later relock of it can skip on.
for (path, content) in [("f/rel/a", A), ("f/rel/b", B), ("f/rel/c", C)] {
client
.create_script(W, &bun_script(path, content, None))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
}
let b_before = versions(&db, "f/rel/b").await;
let c_before = versions(&db, "f/rel/c").await;
assert_eq!(b_before.len(), 1);
assert_eq!(c_before.len(), 1);
// Content-only change on the leaf.
let since = chrono::Utc::now();
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 2).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(
paths,
["f/rel/a", "f/rel/b"],
"the leaf's own job and one no-op relock of its importer, and nothing for c"
);
assert!(
jobs[1]
.2
.contains("Lock unchanged: no new version deployed"),
"b's relock should have found its lock unchanged: {}",
jobs[1].2
);
let b_after = versions(&db, "f/rel/b").await;
let c_after = versions(&db, "f/rel/c").await;
assert_eq!(
b_after.len(),
1,
"an unchanged relock must not mint a version"
);
assert_eq!(live(&b_after).hash, live(&b_before).hash);
assert_eq!(c_after.len(), 1);
assert_eq!(live(&c_after).hash, live(&c_before).hash);
// A dependency change on the leaf.
let since = chrono::Utc::now();
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&bun_script("f/rel/a", A_WITH_LODASH, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 3).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(paths, ["f/rel/a", "f/rel/b", "f/rel/c"]);
for path in ["f/rel/b", "f/rel/c"] {
let vs = versions(&db, path).await;
assert_eq!(
vs.len(),
2,
"{path}: a changed relock deploys a new version"
);
assert!(
vs[0].archived && !vs[1].archived,
"{path}: parent archived, child live"
);
assert!(vs[0].created_at < vs[1].created_at, "{path}: lineage order");
assert!(
vs[1].lock.as_deref().unwrap_or("").contains("lodash"),
"{path}: the new version carries the new lock: {:?}",
vs[1].lock
);
}
},
port,
)
.await;
Ok(())
}
/// A relock that has to wait on its head's row lock, because a deploy of the same path holds
/// it, must find the version that deploy left and requeue itself for it rather than fail. The
/// blocked statement re-checks only the row it selected, which the deploy archived, and comes
/// back empty; the successor is only visible to a fresh read.
#[sqlx::test(fixtures("base"))]
async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(
&db,
async {
for (path, content) in [("f/rel/a", A), ("f/rel/b", B)] {
client
.create_script(W, &bun_script(path, content, None))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
}
// A deploy of b that holds its head's row lock for as long as this transaction lives.
let mut deploy = db.begin().await.unwrap();
let head = fetch_script_for_update("f/rel/b", W, &mut *deploy)
.await
.unwrap()
.unwrap();
let since = chrono::Utc::now();
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
// b's relock skips generation and reaches its commit, where it waits on the lock.
let mut waiting = false;
for _ in 0..300 {
waiting = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock'
AND query LIKE '%FROM script WHERE path = $1%FOR UPDATE%')",
)
.fetch_one(&db)
.await
.unwrap();
if waiting {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert!(waiting, "b's relock never reached the row lock");
// The deploy lands: the head is archived and a successor with its own lock takes
// its place, while the relock is still waiting.
let lock = head.lock.clone().unwrap();
let successor =
deploy_relocked_version(&mut deploy, head, None, Some(&lock), None, None)
.await
.unwrap();
deploy.commit().await.unwrap();
// a's own job, the relock that waited, and the relock it queued for the successor.
wait_for_jobs(&mut completed, 3).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(paths, ["f/rel/a", "f/rel/b", "f/rel/b"], "{jobs:?}");
assert!(
jobs.iter().all(|(_, status, _)| status == "success"),
"no relock may fail on the wait: {jobs:?}"
);
assert!(
jobs[1]
.2
.contains("was deployed while this lock was generated"),
"the waiting relock should have seen the successor: {}",
jobs[1].2
);
assert!(
jobs[2]
.2
.contains("Lock unchanged: no new version deployed"),
"the requeued relock should find the successor's lock current: {}",
jobs[2].2
);
let vs = versions(&db, "f/rel/b").await;
assert_eq!(
vs.len(),
2,
"the deploy's successor and nothing else: {vs:?}"
);
assert_eq!(live(&vs).hash, successor);
},
port,
)
.await;
Ok(())
}
/// A multi-file importer: on a skipped relock each module gets its own last lock back, not the
/// parent script's, so an import's content-only redeploy leaves the importer alone as well.
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let py = |path: &str, content: &str, parent_hash: Option<String>, with_module: bool| {
let mut ns = bun_script(path, content, parent_hash);
ns.language = windmill_api_client::types::ScriptLang::Python3;
if with_module {
ns.modules = Some(std::collections::HashMap::from([(
"helper.py".to_string(),
serde_json::json!({
"content": "def greet(x):\n return 'hi ' + x\n",
"language": "python3"
}),
)]));
}
ns
};
async fn module_lock(db: &Pool<Postgres>) -> Option<String> {
sqlx::query_scalar(
"SELECT modules->'helper.py'->>'lock' FROM script
WHERE workspace_id = $1 AND path = 'f/rel/pb' AND archived = false",
)
.bind(W)
.fetch_one(db)
.await
.unwrap()
}
in_test_worker(
&db,
async {
client
.create_script(W, &py("f/rel/pa", "def main():\n return 'a'\n", None, false))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
client
.create_script(
W,
&py(
"f/rel/pb",
"from f.rel.pa import main as a\nfrom .helper import greet\n\ndef main():\n return greet(a())\n",
None,
true,
),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
let lock_before = module_lock(&db).await;
assert!(lock_before.is_some(), "the module got a lock of its own on deploy");
let since = chrono::Utc::now();
let pa_hash = live(&versions(&db, "f/rel/pa").await).hash;
client
.create_script(
W,
&py(
"f/rel/pa",
"# same dependencies\ndef main():\n return 'a'\n",
Some(format!("{pa_hash:016x}")),
false,
),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 2).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
assert_eq!(paths, ["f/rel/pa", "f/rel/pb"], "{jobs:?}");
assert!(
jobs[1].2.contains("Lock unchanged: no new version deployed"),
"the multi-file importer's relock should be a no-op: {}",
jobs[1].2
);
assert_eq!(versions(&db, "f/rel/pb").await.len(), 1);
assert_eq!(module_lock(&db).await, lock_before, "the module keeps its own lock");
},
port,
)
.await;
Ok(())
}
+5 -2
View File
@@ -266,7 +266,10 @@ def main():
.await
.unwrap();
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
// Empty content leaves every importer's lock as it was, so only the five direct
// importers of the default deps run a job: an unchanged script relock deploys no
// version and so queues nothing for its own importers.
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 5), port).await;
// Note: within a cascade, the same script may be triggered multiple times.
// After the first trigger relocks and stores the hash, subsequent triggers skip.
@@ -295,7 +298,7 @@ def main():
.await
.unwrap();
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await;
in_test_worker(&db, wait_for_jobs_ge(&mut completed, 5), port).await;
let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await;
assert!(
-49
View File
@@ -288,7 +288,6 @@ pub enum ScopeDomain {
Configs,
OAuth,
AI,
AiSkills,
AiEvals, // AI agent eval datasets
Indexer,
@@ -349,7 +348,6 @@ impl ScopeDomain {
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::AiSkills => "ai_skills",
Self::AiEvals => "ai_evals",
Self::Capture => "capture",
Self::Drafts => "drafts",
@@ -405,7 +403,6 @@ impl ScopeDomain {
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"ai_skills" => Some(Self::AiSkills),
"ai_evals" => Some(Self::AiEvals),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
@@ -1202,12 +1199,6 @@ mod tests {
assert_eq!(domain, ScopeDomain::FlowConversations);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
assert_eq!(domain, ScopeDomain::AiSkills);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
}
#[test]
@@ -1368,11 +1359,6 @@ mod tests {
ScopeDomain::from_str("flow_conversations"),
Some(ScopeDomain::FlowConversations)
);
assert_eq!(
ScopeDomain::from_str("ai_skills"),
Some(ScopeDomain::AiSkills)
);
// Test canonical string conversion
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
@@ -1381,41 +1367,6 @@ mod tests {
ScopeDomain::FlowConversations.as_str(),
"flow_conversations"
);
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
}
#[test]
fn test_ai_skills_scope_access() {
let read_scopes = vec!["ai_skills:read".to_string()];
assert!(
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
);
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/get/foo",
"GET"
)
.is_ok());
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_err());
let write_scopes = vec!["ai_skills:write".to_string()];
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_ok());
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/delete/foo",
"DELETE"
)
.is_ok());
}
#[test]
+2 -1
View File
@@ -157,7 +157,8 @@ async fn list_flows(
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users",
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow' \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(o.workspace_id, o.path) as inherited_labels"
])
.left()
@@ -183,9 +183,9 @@ async fn add_granular_acl(
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
} else {
"grant_read"
};
crate::folders::log_folder_permission_change(
&mut *tx,
@@ -0,0 +1,186 @@
//! Request headers reaching a runnable's preprocessor over MCP.
//!
//! The property this pins is structural rather than a filter: the model writes
//! the tool's arguments, which become `event.body`, while the server writes
//! `event.headers`. A model that guesses a header's name can only ever land in
//! `body`, so an identity read from `headers` is one prompt injection cannot
//! forge. Nothing else in the suite exercises MCP argument shaping end to end.
//!
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
#![cfg(feature = "mcp")]
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
/// Echoes the two halves of the event separately, so the assertions can tell
/// which one a value arrived in.
const PREPROCESSOR_SCRIPT: &str = r#"
export async function preprocessor(event: any) {
return {
kind: event.kind,
from_headers: event.headers?.["x-user-id"] ?? "",
from_body: event.body?.x_user_id ?? "",
header_names: Object.keys(event.headers ?? {}).sort(),
};
}
export async function main(kind: string, from_headers: string, from_body: string, header_names: string[]) {
return { kind, from_headers, from_body, header_names };
}
"#;
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
)
.execute(db)
.await?;
Ok(())
}
/// POST one JSON-RPC message. The endpoint answers either `application/json` or
/// a single-event SSE stream, so strip the `data: ` framing before parsing.
async fn mcp_post(port: u16, headers: &[(&str, &str)], body: Value) -> anyhow::Result<Value> {
let mut req = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
))
.header("Authorization", "Bearer MCP_TOKEN")
.header("Accept", "application/json, text/event-stream")
.json(&body);
for (k, v) in headers {
req = req.header(*k, *v);
}
let text = req.send().await?.text().await?;
let payload = text
.lines()
.find_map(|l| l.strip_prefix("data: "))
.unwrap_or(text.trim());
serde_json::from_str(payload).map_err(|e| anyhow::anyhow!("unparseable MCP body {text:?}: {e}"))
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_preprocessor_receives_the_callers_headers(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
insert_mcp_token(&db).await?;
let server = ApiServer::start_mcp(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": SCRIPT_PATH,
"summary": "mcp header probe",
"description": "",
"content": PREPROCESSOR_SCRIPT,
"language": "bun",
"lock": "",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "x_user_id": { "type": "string" } },
"required": []
}
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"create script: {}",
resp.text().await.unwrap_or_default()
);
// A script counts as deployed once it has a lock, which normally arrives from
// a dependency job. Planting an empty one keeps the test to the path under
// test instead of a bun resolution whose timing it does not control.
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
.bind(SCRIPT_PATH)
.execute(&db)
.await?;
let tools = mcp_post(
port,
&[],
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}),
)
.await?;
let tool_name = tools["result"]["tools"]
.as_array()
.and_then(|list| {
list.iter()
.filter_map(|t| t["name"].as_str())
.find(|n| n.contains("mcp__hdr__probe"))
})
.ok_or_else(|| anyhow::anyhow!("the deployed script was not listed as a tool: {tools}"))?
.to_string();
let result = in_test_worker(
db.clone(),
async {
mcp_post(
port,
// Every name the withheld list covers has to be on the wire, or
// asserting its absence proves nothing. `Authorization` is already
// set by `mcp_post`, and `extract_token` reads it before the
// cookie, so sending one does not disturb auth.
&[
("X-User-Id", "alice@corp.example"),
("Cookie", "session=secret"),
("Proxy-Authorization", "Basic Zm9v"),
],
json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
// The model names the header it wants to spoof. Its value is an
// argument, so it can only ever reach `event.body`.
"params": { "name": tool_name, "arguments": { "x_user_id": "attacker@evil.test" } }
}),
)
.await
},
port,
)
.await?;
let text = result["result"]["content"][0]["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("tool call returned no text content: {result}"))?;
let out: Value = serde_json::from_str(text)?;
assert_eq!(out["kind"], "mcp", "preprocessor event kind: {out}");
assert_eq!(
out["from_headers"], "alice@corp.example",
"the caller's header must reach event.headers: {out}"
);
assert_eq!(
out["from_body"], "attacker@evil.test",
"the model's argument must land in event.body, not overwrite the header: {out}"
);
let names: Vec<&str> = out["header_names"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(
names.contains(&"x-user-id"),
"event.headers must carry the request's own headers: {names:?}"
);
for withheld in ["authorization", "cookie", "proxy-authorization"] {
assert!(
!names.contains(&withheld),
"{withheld} is withheld from a preprocessor: {names:?}"
);
}
Ok(())
}
@@ -38,6 +38,85 @@ fn new_script(path: &str, summary: &str, content: &str) -> serde_json::Value {
})
}
/// A supplied lock queues no dependency job, so if the create does not record its hash nothing
/// ever will, and every importer of this script relocks on each of its deploys forever after.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_create_script_persists_supplied_lock_hash(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let path = "u/test-user/supplied_lock";
let lock = r#"{"version":"4","remote":{}}"#;
let mut script = new_script(
path,
"Supplied lock",
"export async function main() { return 42; }",
);
script["lock"] = json!(lock);
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
)))
.json(&script)
.send()
.await?;
assert_eq!(resp.status(), 201, "create: {}", resp.text().await?);
let stored_hash = sqlx::query_scalar!(
"SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
"test-workspace",
path,
)
.fetch_one(&db)
.await?;
assert_eq!(stored_hash, windmill_common::scripts::hash_script(lock));
// A script deployed before the create recorded hashes has no row, and pushing it unchanged
// creates no version to hang one off. Without the write on that path it would keep its
// importers relocking until someone edited it.
sqlx::query!(
"DELETE FROM lock_hash WHERE workspace_id = $1 AND path = $2",
"test-workspace",
path,
)
.execute(&db)
.await?;
// The no-op comparison covers every field, so the push has to carry what the first deploy
// filled in by itself; `auto_parent` both resolves the parent and keeps the hash distinct.
script["auto_parent"] = json!(true);
script["ws_error_handler_muted"] = json!(false);
script["assets"] = json!([]);
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create?skip_if_noop=true"
)))
.json(&script)
.send()
.await?;
assert_eq!(resp.status(), 201, "no-op push: {}", resp.text().await?);
let versions: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM script WHERE workspace_id = $1 AND path = $2",
"test-workspace",
path,
)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert_eq!(versions, 1, "no-op push must not create a version");
let repaired_hash = sqlx::query_scalar!(
"SELECT lockfile_hash FROM lock_hash WHERE workspace_id = $1 AND path = $2",
"test-workspace",
path,
)
.fetch_one(&db)
.await?;
assert_eq!(repaired_hash, windmill_common::scripts::hash_script(lock));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -797,10 +876,12 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy(
// What a deploy leaves behind: the old head archived, a new one live at the path.
// Copied through a temp table so this does not have to restate every column.
sqlx::query("CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1")
.bind(head)
.execute(&mut *winner)
.await?;
sqlx::query(
"CREATE TEMP TABLE superseding ON COMMIT DROP AS SELECT * FROM script WHERE hash = $1",
)
.bind(head)
.execute(&mut *winner)
.await?;
sqlx::query("UPDATE superseding SET hash = $1, archived = false, parent_hashes = ARRAY[$2]")
.bind(head + 1)
.bind(head)
@@ -818,7 +899,10 @@ async fn test_update_script_reports_losing_to_a_concurrent_deploy(
let resp = tokio::time::timeout(std::time::Duration::from_secs(20), update).await??;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "losing the race should not read as success: {body}");
assert_eq!(
status, 400,
"losing the race should not read as success: {body}"
);
assert!(
body.contains("deployed to concurrently"),
"the loser must say it was superseded, not that the script is missing: {body}"
@@ -917,3 +917,79 @@ async fn test_change_user_email_leaves_group_identities(db: Pool<Postgres>) -> a
Ok(())
}
/// An address with no `password` row can own a draft, and the account paths carry the delete and
/// rename that no foreign key does any more.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let global_base = format!("http://localhost:{port}/api/users");
// The destination of the rename below already holds a draft of the same item — it belongs to
// an accountless principal, so `change_email`'s "address is free" check does not see it.
sqlx::query!(
"INSERT INTO draft(workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/ext/s', 'script', '{}'::json, 'ext-jwt@windmill.dev'),
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"moving\"}'::json, 'test2@windmill.dev'),
('test-workspace', 'u/two/s', 'script', '{\"summary\": \"displaced\"}'::json, 'renamed@windmill.dev'),
('test-workspace', 'u/three/s', 'script', '{}'::json, 'test3@windmill.dev')"
)
.execute(&db)
.await?;
// A null username is how the legacy workspace-level row is encoded, so an owner nobody can
// name must be absent from the owner circles rather than pose as one.
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list?all_users=true"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let listed = resp.json::<serde_json::Value>().await?;
let ext = listed
.as_array()
.unwrap()
.iter()
.find(|d| d["path"] == "u/ext/s")
.expect("the accountless owner's draft is listed");
assert_eq!(ext.get("draft_users"), None);
let resp = authed(client().post(format!("{global_base}/change_email/test2@windmill.dev")))
.json(&json!({ "new_email": "renamed@windmill.dev" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "change_email: {}", resp.text().await?);
let moved = sqlx::query!(
"SELECT email, value->>'summary' AS summary FROM draft WHERE path = 'u/two/s'"
)
.fetch_all(&db)
.await?;
assert_eq!(
moved
.iter()
.map(|r| (r.email.as_deref(), r.summary.as_deref()))
.collect::<Vec<_>>(),
vec![(Some("renamed@windmill.dev"), Some("moving"))],
"the moving account's draft wins the unique index it now collides on"
);
let resp = authed(client().delete(format!("{global_base}/delete/test3@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete_user: {}", resp.text().await?);
let remaining = sqlx::query_scalar!("SELECT path FROM draft ORDER BY path")
.fetch_all(&db)
.await?;
assert_eq!(
remaining,
vec!["u/ext/s".to_string(), "u/two/s".to_string()],
"the deleted account's draft goes, the accountless owner's stays"
);
Ok(())
}
@@ -889,6 +889,53 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row(
Ok(())
}
/// A workspace with no provider of its own is served the instance config, but the
/// `copilot_disabled` flag must still come from the workspace's own row.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_keeps_workspace_copilot_disabled_over_instance_fallback(
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/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(json!({ "copilot_disabled": true }))
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(
settings["providers"]["openai"]["models"][0], "gpt-4o-mini",
"instance providers are still served"
);
assert_eq!(settings["copilot_disabled"], true);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_error_handler_instance_alerts_fallback(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -941,7 +988,12 @@ async fn test_error_handler_instance_alerts_fallback(db: Pool<Postgres>) -> anyh
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "disable on fork: {}", resp.text().await?);
assert_eq!(
resp.status(),
200,
"disable on fork: {}",
resp.text().await?
);
assert!(!stored().await?);
Ok(())
@@ -1044,9 +1096,11 @@ async fn test_create_service_account_drops_orphaned_group_memberships(
.await?;
// Same username, different workspace, and very much alive — must not be touched.
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other', 'svc_acct')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO group_ (workspace_id, name, summary) VALUES
('other-workspace', 'all', 'All users'),
@@ -416,11 +416,31 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
let mut headers = HeaderMap::new();
// A reverse proxy consumes hop-by-hop headers instead of forwarding them and
// drops every header named by `Connection`, so a script could use one to strip
// the sandbox headers this function adds before they reach the browser.
const HOP_BY_HOP_HEADERS: [&str; 9] = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
if let Some(windmill_headers) = windmill_headers {
for (k, v) in windmill_headers {
let k = HeaderName::from_str(k.as_str()).map_err(|err| {
Error::internal_err(format!("Invalid header name {k}: {err}"))
})?;
if HOP_BY_HOP_HEADERS.contains(&k.as_str()) {
return Err(Error::ExecutionErr(format!(
"windmill_headers cannot set the hop-by-hop header \"{k}\""
)));
}
let v = HeaderValue::from_str(v.as_str()).map_err(|err| {
Error::internal_err(format!("Invalid header value {v}: {err}"))
})?;
@@ -428,6 +448,22 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
}
}
// The script controls the content type and body, and run_wait_result and sync
// HTTP routes are reachable by top-level GET navigation with the session cookie:
// sandbox the document into an opaque origin so HTML can never run with the
// viewer's session. Inserted after `wm_headers` so a script cannot override it.
headers.insert(
http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"sandbox allow-scripts allow-forms allow-popups \
allow-popups-to-escape-sandbox allow-downloads allow-modals",
),
);
if let Some(content_type) = windmill_content_type {
let serialized_json_result = result_value
.map(|val| val.get().to_owned())
@@ -1104,6 +1140,56 @@ mod result_to_response_tests {
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
"text/html"
);
assert_sandboxed(resp.headers());
assert_eq!(body_bytes(resp).await, b"<h1>hi</h1>");
}
fn assert_sandboxed(headers: &HeaderMap) {
assert_eq!(
headers.get(http::header::X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff"
);
let csp = headers
.get(http::header::CONTENT_SECURITY_POLICY)
.expect("content-security-policy")
.to_str()
.unwrap();
assert!(csp.starts_with("sandbox "), "csp: {csp}");
assert!(!csp.contains("allow-same-origin"), "csp: {csp}");
}
#[tokio::test]
async fn custom_headers_cannot_override_sandbox() {
// wm_headers is script-controlled: a content-type set there replaces the JSON
// one even without wm_content_type, and the sandbox headers must survive an
// attempt to override them.
let resp = result_to_response(
raw(
r#"{"wm_headers":{"content-type":"text/html","content-security-policy":"default-src *","x-content-type-options":"none"},"result":"<h1>hi</h1>"}"#,
),
true,
)
.expect("response");
assert_eq!(
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
"text/html"
);
assert_sandboxed(resp.headers());
}
#[tokio::test]
async fn hop_by_hop_custom_headers_are_rejected() {
// A proxy drops every header named by `Connection`, which would strip the
// sandbox headers on the way to the browser.
for name in ["connection", "Connection", "transfer-encoding", "upgrade"] {
let res = result_to_response(
raw(&format!(
r#"{{"wm_content_type":"text/html","wm_headers":{{"{name}":"content-security-policy, x-content-type-options"}},"result":"<h1>hi</h1>"}}"#
)),
true,
);
assert!(res.is_err(), "hop-by-hop header must be rejected: {name}");
}
}
}
+27 -3
View File
@@ -39,7 +39,7 @@ use sqlx::{FromRow, Postgres, Transaction};
use std::{collections::HashMap, sync::Arc};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_dep_map::process_relative_imports;
use windmill_dep_map::{lock_hash::record_lock_hashes, process_relative_imports};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_common::{
@@ -216,12 +216,15 @@ async fn list_scripts(
// a member of has no `usr` row, so fall back to their instance-derived username
// (`password.username`), or their email when derivation is disabled — this keeps the
// raw email out of the payload whenever a derived username exists. The genuine
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL).
// NULL-email legacy row stays None (no `usr`/`password` match, `d.email` is NULL),
// which is why an owner that resolves to no name at all — an external JWT's subject
// has neither row — is dropped: None is read as "legacy" downstream.
"(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users",
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script' \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(o.workspace_id, o.path) as inherited_labels"
])
.left()
@@ -1070,6 +1073,14 @@ fn modules_eq(
}
}
/// Recorded for the empty lock a codebase or a language with no lock generation carries as well as
/// for a real one: the worker writes `hash_script("")` in the same situation, and a path going from
/// a real lock to an empty one has to stop matching what its importers recorded, or they wrongly
/// skip rather than merely relock too often.
fn lock_hash_entry(path: &str, lock: &str) -> [(String, i64); 1] {
[(path.to_string(), hash_script(lock))]
}
async fn create_script_internal<'c>(
mut ns: NewScript,
w_id: String,
@@ -1337,6 +1348,12 @@ async fn create_script_internal<'c>(
parent_hash = %p_hash.0,
"Skipping no-op script deploy (identical to parent)"
);
// The version is unchanged, but the row recording its lock's hash may never have
// been written — nothing else writes it for a supplied lock, and a path only ever
// pushed unchanged would otherwise keep its importers relocking forever.
if let Some(lock) = ps.lock.as_deref() {
record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?;
}
return Ok((p_hash.clone(), tx, None, Vec::new()));
}
@@ -1884,6 +1901,13 @@ async fn create_script_internal<'c>(
.execute(&mut *tx)
.await?;
// A lock that is not left to a dependency job queues none, so this is the only place its hash
// can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed,
// so leaving the row out makes every importer of this path relock on every deploy of it.
if let Some(lock) = lock.as_deref() {
record_lock_hashes(&mut tx, &w_id, &lock_hash_entry(&ns.path, lock)).await?;
}
// Update ci_test_reference table for test scripts
// Delete by both new and old path to handle renames
let old_path = parent_hashes_and_perms.as_ref().map(|x| x.p_path.as_str());
+91 -33
View File
@@ -284,15 +284,28 @@ pub async fn test_s3_bucket(
use bytes::Bytes;
use futures::StreamExt;
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
// object store usually lives on the local/private network and all authenticated users are
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
// The probe executes on the API server itself and reflects the upstream response into the
// error, so any authenticated caller could otherwise use it as an SSRF / port-scan primitive
// against the server's network, exfiltrate its ambient credentials, or write to its local
// disk (see validate_object_storage_test). That holds on self-hosted instances as much as on
// Cloud, so only super admins get the unrestricted path.
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
let restrict = !is_super_admin && *CLOUD_HOSTED;
let restrict = !is_super_admin;
if restrict {
validate_object_storage_test(&test_s3_bucket).await?;
validate_object_storage_test(&test_s3_bucket)
.await
.map_err(|e| match e {
// A job token never counts as a super admin (it is capped at workspace admin), so
// a super admin calling this route from a script is told why rather than that
// they lack a privilege they hold.
error::Error::NotAuthorized(msg) if authed.job_id.is_some() => {
error::Error::NotAuthorized(format!(
"{msg} A job token ($WM_TOKEN) is never treated as a super admin; call \
this route with a user token instead."
))
}
e => e,
})?;
}
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
@@ -355,8 +368,8 @@ pub async fn test_s3_bucket(
}
}
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller.
// The probe runs on the API server, so without these constraints an authenticated
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
// requests with the instance role (credential exfiltration), or reading/writing the server's local
// disk (filesystem object store).
@@ -366,6 +379,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
opt.as_ref().is_some_and(|s| !s.is_empty())
}
// Every refusal names the way out: the resource usually works in jobs (workers reach the
// endpoint directly), so without it the refusal reads as a broken resource.
const ALTERNATIVE: &str =
"Ask a super admin to run it, or test the resource from a script, which runs on a worker.";
// Reject backends that rely on the server's identity or local filesystem, require explicit
// credentials for the rest (so the server never falls back to its own ambient credentials), and
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
@@ -376,20 +394,25 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
let effective_endpoint: Option<String> = match settings {
ObjectSettings::Filesystem(_) => {
return Err(error::Error::NotAuthorized(
"Testing a local filesystem object store requires a super admin".to_string(),
"Testing a local filesystem object store requires a super admin: it runs on the \
Windmill server and reads and writes the server's local disk. Ask a super admin \
to run it."
.to_string(),
));
}
ObjectSettings::AwsOidc(_) => {
return Err(error::Error::NotAuthorized(
"Testing OIDC-based object storage requires a super admin".to_string(),
));
return Err(error::Error::NotAuthorized(format!(
"Testing OIDC-based object storage requires a super admin: it runs on the \
Windmill server with the server's own identity. {ALTERNATIVE}"
)));
}
ObjectSettings::S3(s3) => {
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
return Err(error::Error::NotAuthorized(
"Testing S3 storage without explicit credentials requires a super admin"
.to_string(),
));
return Err(error::Error::NotAuthorized(format!(
"Testing S3 storage without an explicit access key and secret key requires a \
super admin: it runs on the Windmill server, which would use its own ambient \
credentials. {ALTERNATIVE}"
)));
}
let region = s3
.region
@@ -413,10 +436,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
}
ObjectSettings::Azure(azure) => {
if !non_empty(&azure.access_key) {
return Err(error::Error::NotAuthorized(
"Testing Azure storage without an explicit access key requires a super admin"
.to_string(),
));
return Err(error::Error::NotAuthorized(format!(
"Testing Azure storage without an explicit access key requires a super admin: \
it runs on the Windmill server, which would use its own ambient credentials. \
{ALTERNATIVE}"
)));
}
Some(
azure
@@ -432,10 +456,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
// otherwise an untrusted caller could probe with the server's identity (the very
// SSRF/credential-exfil this function guards against).
if windmill_object_store::gcs_service_account_key_is_blank(&gcs.service_account_key) {
return Err(error::Error::NotAuthorized(
"Testing GCS storage without a service account key requires a super admin"
.to_string(),
));
return Err(error::Error::NotAuthorized(format!(
"Testing GCS storage without a service account key requires a super admin: \
it runs on the Windmill server, which would use its own ambient credentials. \
{ALTERNATIVE}"
)));
}
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
@@ -492,10 +517,15 @@ async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
// attempts (a name resolving to both a public and a private address).
for addr in addrs {
if is_forbidden_ip(addr.ip()) {
return Err(error::Error::NotAuthorized(
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
.to_string(),
));
// The resolved address stays out of the message: it is the server's resolver's
// answer, and this message is only ever shown to the caller being constrained.
return Err(error::Error::NotAuthorized(format!(
"Testing object storage at '{host}', which resolves to a private, loopback, or \
link-local address, requires a super admin: this test runs on the Windmill \
server, which is not allowed to probe internal addresses for non-super-admins. \
Ask a super admin to run it, or test the resource from a script, which runs on \
a worker."
)));
}
}
Ok(())
@@ -2004,6 +2034,15 @@ struct CachedResourceType {
#[allow(dead_code)]
app: String,
description: Option<String>,
/// Doubly optional, and read through a wrapping deserializer: this struct also
/// decodes the on-disk cache, where an absent key means "written before the
/// column, leave the stored extension alone" and an explicit null means the hub
/// dropped it. Plain serde folds both into `None`.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
format_extension: Option<Option<String>>,
}
#[derive(serde::Deserialize)]
@@ -2013,6 +2052,8 @@ struct HubResourceTypeRaw {
schema: Option<String>,
app: String,
description: Option<String>,
#[serde(default)]
format_extension: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
@@ -2054,6 +2095,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect())
@@ -2107,10 +2149,12 @@ async fn sync_cached_resource_types(
for rt in &resource_types {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.fetch_one(&db)
.await?;
@@ -2120,13 +2164,27 @@ async fn sync_cached_resource_types(
}
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// Whether the payload carried the key at all is what decides: present
// (even as null) is authoritative and may clear, absent means a cache
// written before the column and must leave the stored value alone.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
-- A fileset is a set of files, so it cannot also be one file.
-- Create and update reject the pair; this writer bypasses both, so
-- it declines the extension rather than persisting the forbidden
-- combination onto a same-named local fileset.
format_extension = CASE
WHEN resource_type.is_fileset THEN NULL
WHEN $5 THEN EXCLUDED.format_extension
ELSE resource_type.format_extension END,
edited_at = now()",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.execute(&db)
.await?;
+7 -1
View File
@@ -1239,6 +1239,7 @@ async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Resu
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &authed.email).await?;
audit_log(
&mut *tx,
@@ -1661,6 +1662,7 @@ async fn delete_user(
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
let usernames = sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 RETURNING username",
@@ -1869,7 +1871,7 @@ async fn change_user_email(
.execute(&mut *tx)
.await?;
// ---- account ---- (draft.email follows through its ON UPDATE CASCADE fkey)
// ---- account ----
sqlx::query!(
"UPDATE password SET email = $1 WHERE email = $2",
&new_email,
@@ -1883,6 +1885,7 @@ async fn change_user_email(
}
_ => e.into(),
})?;
windmill_common::user_drafts::rename_drafts_of_email(&mut *tx, &old_email, &new_email).await?;
sqlx::query!(
"UPDATE usr SET email = $1 WHERE email = $2",
@@ -3539,6 +3542,9 @@ async fn overwrite_global_users(
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
// Replaces the account table, so — unlike the paths that remove one account — it deliberately
// does not call `delete_drafts_of_email`: the addresses are about to be reinstated, and
// dropping every draft on the instance to restore accounts would be pure collateral.
sqlx::query!("DELETE FROM password")
.execute(&mut *tx)
.await?;
@@ -12,6 +12,7 @@ use windmill_api_auth::{
};
use windmill_api_users::users::WorkspaceInvite;
use windmill_common::email_oss::send_email_if_possible;
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
use windmill_common::webhook::WebhookShared;
use windmill_common::{BASE_URL, DB};
@@ -5799,9 +5800,8 @@ async fn clone_workspace_data(
// Clone the forker's own per-user drafts (plus the legacy NULL-email
// workspace draft, if any) so they keep their pending edits in the
// fork. Other users' drafts are intentionally NOT cloned — they don't
// own a `usr` row in the fork (see `clone_workspace_full`) so their
// drafts would dangle and the home-page `draft_users` aggregate would
// surface them as duplicate legacy entries.
// own a `usr` row in the fork (see `clone_workspace_full`), so those
// drafts would belong to someone the fork holds no membership for.
clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?;
// Clone workspace runnable dependencies and dependency map
@@ -7140,7 +7140,16 @@ async fn clone_workspace_runnable_dependencies(
.execute(&mut **tx)
.await?;
// Clone dependency_map to preserve import relationships
// Recorded so the clone's own relocks have something to match; with no row they record NULL
// and nothing in it ever skips. Hashed from the locks the clone holds rather than copied from
// the source's rows, which are only as current as the last write to them: one left stale by a
// supplied lock deployed before this was recorded names a lock the clone no longer has, and an
// importer that resolved against the real one would then skip a relock it needed.
record_lock_hashes_for_workspace(tx, target_workspace_id).await?;
// Deliberately without `imported_lockfile_hash`: it records what an importer resolved against
// when it was last locked, which nothing here can establish for the version the clone got.
// Left NULL, every importer relocks once and re-anchors both sides to what the clone holds.
sqlx::query!(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id)
SELECT $1, importer_path, importer_kind, imported_path, importer_node_id
-196
View File
@@ -16532,202 +16532,6 @@
}
}
},
"/w/{workspace}/ai_skills/list": {
"get": {
"summary": "list the workspace AI chat skills (name + description only)",
"operationId": "listAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"responses": {
"200": {
"description": "skill listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"required": [
"name",
"description"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/get/{name}": {
"get": {
"summary": "get a workspace AI chat skill including its instructions",
"operationId": "getAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "skill",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/upload": {
"post": {
"summary": "upsert workspace AI chat skills (admin only)",
"operationId": "uploadAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"skills"
],
"properties": {
"skills": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9-]+$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"instructions": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "uploaded",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/delete/{name}": {
"delete": {
"summary": "delete a workspace AI chat skill (admin only)",
"operationId": "deleteAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "deleted",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
"get": {
"summary": "get raw app data by",
-135
View File
@@ -17063,141 +17063,6 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
responses:
'200':
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: ^[a-z0-9-]+$
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
'200':
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
+18 -130
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.800.1
version: 1.803.0
title: Windmill API
contact:
@@ -10456,7 +10456,7 @@ paths:
summary: run script by path
operationId: runScriptByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."
x-mcp-tool-include-query-params: []
tags:
- job
@@ -12534,133 +12534,6 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: "^[a-z0-9-]+$"
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
"200":
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -13941,7 +13814,7 @@ paths:
summary: run flow by path
operationId: runFlowByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."
x-mcp-tool-include-query-params: []
tags:
- job
@@ -27262,6 +27135,13 @@ components:
type: object
additionalProperties:
$ref: "#/components/schemas/ModelPriceOverride"
copilot_disabled:
type: boolean
description: >-
Hides the Windmill AI assistant (chat, sessions, code generation, completion,
fixes) from the workspace UI. Read from the workspace's own settings even when
the providers served fall back to the instance config. AI agent steps and the
AI sandbox in flows are unaffected.
FreeTierInfo:
type: object
@@ -29114,6 +28994,8 @@ components:
- "igroup.delete"
- "igroup.adduser"
- "igroup.removeuser"
- "instance_groups.jit_adduser"
- "instance_groups.jit_removeuser"
- "variables.decrypt_secret"
- "workspaces.read_encryption_key"
- "workspaces.edit_command_script"
@@ -29612,6 +29494,12 @@ components:
type: string
is_fileset:
type: boolean
format_extension:
type: string
nullable: true
description: >-
File extension for a type whose value is one file rather than a set
of fields. Omit to leave it unchanged; send null to clear it.
TriggerHistoryEntry:
type: object
+6
View File
@@ -445,6 +445,12 @@ pub struct AIConfig {
/// Only models whose rates differ from the built-in table are stored.
#[serde(skip_serializing_if = "Option::is_none")]
pub model_pricing: Option<HashMap<String, ModelPriceOverride>>,
/// Hides the Windmill AI assistant (chat, sessions, generation, completion, fixes) from
/// the workspace UI. Only the workspace's own row is consulted: the flag holds even when
/// the providers served come from the instance config or the free tier. AI agent steps
/// and the AI sandbox are unaffected, so the providers stay in force.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub copilot_disabled: bool,
}
/// Negotiated rates in USD per million tokens. An unset cache rate is read as the
-394
View File
@@ -1,394 +0,0 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{Extension, Json, Path},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_skills))
.route("/get/{name}", get(get_skill))
.route("/upload", post(upload_skills))
.route("/delete/{name}", delete(delete_skill))
}
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
#[derive(Serialize)]
pub struct SkillListItem {
pub name: String,
pub description: String,
}
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
#[derive(Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
}
#[derive(Deserialize)]
pub struct UploadSkills {
pub skills: Vec<SkillUpload>,
}
#[derive(Deserialize)]
pub struct SkillUpload {
pub name: String,
pub description: String,
pub instructions: String,
}
const MAX_SKILLS_PER_UPLOAD: usize = 50;
// Every stored skill's name + description is advertised in the global AI chat
// system prompt, so bound the total a workspace can accumulate across uploads.
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
// `name` and `description` follow the Claude SKILL.md spec
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
// so matching the upstream limits keeps skills portable with Claude Code.
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
fn validate_skill(skill: &SkillUpload) -> Result<()> {
let name = skill.name.trim();
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
return Err(Error::BadRequest(format!(
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
skill.name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(Error::BadRequest(format!(
"skill name {name:?} must only contain lowercase letters, digits or '-'"
)));
}
if skill.description.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
)));
}
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
return Err(Error::BadRequest(format!(
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
)));
}
if skill.instructions.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} has an empty SKILL.md body"
)));
}
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
return Err(Error::BadRequest(format!(
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
)));
}
Ok(())
}
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
/// The insert upserts by name, so a duplicate would silently keep only the last
/// and make the reported/audited count wrong.
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
let mut names = Vec::with_capacity(skills.len());
let mut seen = HashSet::with_capacity(skills.len());
for skill in skills {
let name = skill.name.trim().to_string();
if !seen.insert(name.clone()) {
return Err(Error::BadRequest(format!(
"duplicate skill name {name:?} in upload"
)));
}
names.push(name);
}
Ok(names)
}
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
/// Uploads upsert, so names already present (`replacing`) don't count as new.
fn check_workspace_skill_capacity(
existing_total: i64,
replacing: i64,
upload_count: usize,
) -> Result<()> {
let new_count = upload_count as i64 - replacing;
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
return Err(Error::BadRequest(format!(
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
)));
}
Ok(())
}
async fn list_skills(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<SkillListItem>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
&w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| SkillListItem { name: r.name, description: r.description })
.collect(),
))
}
async fn get_skill(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Skill> {
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
row.map(|r| {
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
})
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
}
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
/// are left untouched — removal goes through `delete_skill`.
async fn upload_skills(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(payload): Json<UploadSkills>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
if payload.skills.is_empty() {
return Err(Error::BadRequest("no skills to upload".to_string()));
}
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
return Err(Error::BadRequest(format!(
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
)));
}
for skill in &payload.skills {
validate_skill(skill)?;
}
let names = collect_upload_names(&payload.skills)?;
let mut tx = db.begin().await?;
let counts = sqlx::query!(
r#"SELECT
COUNT(*)::bigint AS "total!",
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
FROM ai_skill
WHERE workspace_id = $1"#,
&w_id,
&names
)
.fetch_one(&mut *tx)
.await?;
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
for (skill, name) in payload.skills.iter().zip(names.iter()) {
sqlx::query!(
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (workspace_id, name) DO UPDATE
SET description = EXCLUDED.description,
instructions = EXCLUDED.instructions,
edited_at = now(),
edited_by = EXCLUDED.edited_by"#,
&w_id,
name,
skill.description,
skill.instructions,
&authed.username,
)
.execute(&mut *tx)
.await?;
}
let audit_resource = names.join(",");
audit_log(
&mut *tx,
&authed,
"ai_skills.upload",
ActionKind::Update,
&w_id,
Some(&audit_resource),
Some([("skill_count", &names.len().to_string()[..])].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"Uploaded {} skill(s) to workspace {}",
payload.skills.len(),
&w_id
))
}
async fn delete_skill(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
let deleted = sqlx::query_scalar!(
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
if deleted.is_none() {
tx.commit().await?;
return Err(Error::NotFound(format!(
"no skill named {name:?} in workspace {w_id}"
)));
}
audit_log(
&mut *tx,
&authed,
"ai_skills.delete",
ActionKind::Delete,
&w_id,
Some(&name),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Deleted skill {name} from workspace {w_id}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn skill() -> SkillUpload {
SkillUpload {
name: "test-skill".to_string(),
description: "Useful for tests".to_string(),
instructions: "# Test\n\nDo the thing.".to_string(),
}
}
#[test]
fn validate_skill_rejects_oversized_description() {
let mut skill = skill();
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_instructions() {
let mut skill = skill();
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_name() {
let mut skill = skill();
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_non_slug_name() {
// Uppercase, underscore, space and punctuation are all outside the
// Claude SKILL.md `[a-z0-9-]` name charset.
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
let mut skill = skill();
skill.name = bad.to_string();
assert!(
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
"{bad:?} should be rejected"
);
}
}
#[test]
fn validate_skill_counts_description_in_characters() {
// 1024 two-byte chars exceed the byte limit but sit exactly on the
// character limit, so they must be accepted.
let mut skill = skill();
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
assert!(validate_skill(&skill).is_ok());
}
#[test]
fn workspace_capacity_allows_replacement_at_cap() {
// Already at the cap, but the upload only replaces an existing skill.
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
}
#[test]
fn workspace_capacity_rejects_new_skill_over_cap() {
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(matches!(
check_workspace_skill_capacity(at_cap, 0, 1),
Err(Error::BadRequest(_))
));
}
#[test]
fn collect_upload_names_trims_and_collects() {
let names = collect_upload_names(&[skill()]).unwrap();
assert_eq!(names, vec!["test-skill".to_string()]);
}
#[test]
fn collect_upload_names_rejects_duplicates() {
// Names are compared after trimming, so whitespace can't smuggle a dup in.
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
assert!(matches!(
collect_upload_names(&[skill(), dup]),
Err(Error::BadRequest(_))
));
}
}
+2 -1
View File
@@ -491,7 +491,8 @@ async fn list_apps(
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app')) as draft_users",
WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ IN ('app', 'raw_app') \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users",
"folder_labels(app.workspace_id, app.path) as inherited_labels",
])
.left()
+4
View File
@@ -214,6 +214,9 @@ fn list_drafts_query(all_users: bool) -> String {
// row: fall back to their instance-derived username (`password.username`), or
// their email when derivation is disabled (`password.username` is NULL). This
// keeps the raw email out of the payload whenever a derived username exists.
// A null username means the legacy row downstream, so an owner that resolves to
// no name at all — an external JWT's subject has neither row — is dropped rather
// than surfaced as a second legacy entry.
let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN (
SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END))
ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN du.email END) NULLS LAST)
@@ -221,6 +224,7 @@ fn list_drafts_query(all_users: bool) -> String {
LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email
LEFT JOIN password p ON p.email = du.email AND p.super_admin = true
WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ
AND (du.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
) ELSE NULL END"#;
// Default lists the user's own drafts AND the legacy NULL-email rows; with
// `all_users` the filter is dropped to list every workspace draft.
+104 -10
View File
@@ -18,6 +18,7 @@ use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
@@ -108,6 +109,7 @@ use windmill_common::{
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
query_builders,
scripts::{ScriptHash, ScriptLang},
users::username_to_permissioned_as,
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
@@ -8131,6 +8133,79 @@ pub async fn run_wait_result_flow_by_version(
.await
}
/// Whether request-supplied SQL from an operator may run. Operators can only run deployed
/// code, so a request their job token (`WM_TOKEN`) authenticates comes from code a
/// non-operator authored. The job must still be running, and the request must have the
/// shape `wmill.datatable()` sends (PostgreSQL against a `datatable://` database), so a
/// WM_TOKEN that leaked into job logs cannot be replayed to reach another target while the
/// job lives, in particular DuckDB, which runs in-process in the worker.
///
/// What it does permit is any statement against the workspace's data tables, writes and DDL
/// included: the helper's body is an unrestricted SQL template and data tables carry no
/// per-user ACL. Narrowing that is a separate decision from this exemption.
///
/// The database argument is only half the target: the executor honors a `-- database`
/// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused.
/// Check them against the code the executor runs rather than the request's `content`, which
/// is not the same string once a `WM_INTERNAL_DB` marker expands.
async fn operator_may_run_datatable_query(
db: &DB,
w_id: &str,
job_id: Option<Uuid>,
language: Option<&ScriptLang>,
content: &str,
args: Option<&HashMap<String, Box<JsonRawValue>>>,
) -> error::Result<bool> {
let Some(job_id) = job_id else {
return Ok(false);
};
if language != Some(&ScriptLang::Postgresql) {
return Ok(false);
}
// Parse the directives out of the code the executor actually runs: it expands a
// `WM_INTERNAL_DB` marker first, and a directive can be embedded in the expansion.
// An expansion that overrides the language would run something other than the SQL the
// language check above cleared, so it is refused along with a malformed marker.
let executed =
match query_builders::try_expand_internal_db_query(content, &ScriptLang::Postgresql) {
Some(Ok(expanded)) if expanded.language_override.is_none() => Cow::Owned(expanded.code),
Some(_) => return Ok(false),
None => Cow::Borrowed(content),
};
if windmill_parser_sql::parse_db_resource(&executed).is_some()
|| !matches!(windmill_parser_sql::parse_s3_mode(&executed), Ok(None))
{
return Ok(false);
}
let targets_datatable = args
.and_then(|args| args.get("database"))
.and_then(|database| serde_json::from_str::<String>(database.get()).ok())
.is_some_and(|database| database.starts_with("datatable://"));
if !targets_datatable {
return Ok(false);
}
Ok(sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
job_id,
w_id
)
.fetch_optional(db)
.await?
.unwrap_or(false))
}
/// The refusal an operator gets from a preview route. Inside a job the caller never ran a
/// preview themselves, so name the one thing the job's token may do.
fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
let reason = if job_id.is_some() {
"Operators cannot run preview jobs for security reasons: from inside a job, an \
operator may only run a wmill.datatable() query while that job is running"
} else {
"Operators cannot run preview jobs for security reasons"
};
error::Error::NotAuthorized(reason.to_string())
}
async fn run_preview_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -8142,9 +8217,20 @@ async fn run_preview_script(
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
// A deferred run would outlive the running job the exemption keys off.
if run_query.get_scheduled_for(&db).await?.is_some()
|| !operator_may_run_datatable_query(
&db,
&w_id,
authed.job_id,
preview.language.as_ref(),
preview.content.as_deref().unwrap_or_default(),
preview.args.as_ref(),
)
.await?
{
return Err(operator_preview_refusal(authed.job_id));
}
}
// Preview runs arbitrary, request-supplied code. require_path_read_access_for_preview
// only checks folder/namespace *read* access (and is a no-op when path is null), so a
@@ -8239,13 +8325,21 @@ async fn run_inline_preview_script(
Path(w_id): Path<String>,
Json(preview): Json<PreviewInline>,
) -> error::Result<Response> {
// Same arbitrary-code class as run_preview_script: operators are blocked from
// running request-supplied code, and a narrowly-scoped token must not escape
// its scope through inline preview.
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
// Same arbitrary-code class as run_preview_script, and every worker and standalone
// server exposes this route, so an operator is refused on the same terms. A
// narrowly-scoped token must not escape its scope through inline preview either.
if authed.is_operator
&& !operator_may_run_datatable_query(
&db,
&w_id,
job_id,
Some(&preview.language),
&preview.content,
preview.args.as_ref(),
)
.await?
{
return Err(operator_preview_refusal(job_id));
}
check_scopes(&authed, || format!("jobs:run"))?;
if let Some(job_id) = job_id {
+7 -9
View File
@@ -69,7 +69,6 @@ mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_skills;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -453,15 +452,15 @@ pub async fn run_server(
// unless they are allowed — hence a separate layer rather than widening the
// one every other route shares. (`Mcp-Param-*` is only sent for tool inputs
// annotated with `x-mcp-header`, which no tool here declares.)
//
// The request's own header list is mirrored rather than enumerated: a browser
// MCP client may send any custom name for a preprocessor to read, and no fixed
// list could cover them. Nothing is granted by echoing it: the origin is
// `Any`, so browsers never attach credentials, and the endpoint authenticates
// each request on its own.
let mcp_cors = CorsLayer::new()
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
.allow_headers([
http::header::CONTENT_TYPE,
http::header::AUTHORIZATION,
http::HeaderName::from_static("mcp-protocol-version"),
http::HeaderName::from_static("mcp-method"),
http::HeaderName::from_static("mcp-name"),
])
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
// The 401 challenge is how a client discovers where to authorize (RFC 9728),
// and it is not a safelisted response header, so without this a browser
// client sees an empty one and has no way to begin the OAuth flow.
@@ -713,7 +712,6 @@ pub async fn run_server(
Router::new()
})
.nest("/ai", ai::workspaced_service())
.nest("/ai_skills", ai_skills::workspaced_service())
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
.nest(
"/path_autocomplete",
@@ -878,7 +878,7 @@ is, a different one moves it there and archives the old path"),
EndpointTool {
name: Cow::Borrowed("runScriptByPath"),
description: Cow::Borrowed("run script by path"),
instructions: Cow::Borrowed("You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."),
instructions: Cow::Borrowed("You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."),
path: Cow::Borrowed("/w/{workspace}/jobs/run/p/{path}"),
method: Cow::Borrowed("POST"),
path_params_schema: Some(serde_json::json!({
@@ -1419,7 +1419,7 @@ is, a different one moves it there and archives the old path"),
EndpointTool {
name: Cow::Borrowed("runFlowByPath"),
description: Cow::Borrowed("run flow by path"),
instructions: Cow::Borrowed("You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."),
instructions: Cow::Borrowed("You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected. A resource argument (schema format `resource-<type>`) is the bare string `$res:<path>` as that whole argument value -- not a wrapper object, not a plain path. A variable is `$var:<path>`."),
path: Cow::Borrowed("/w/{workspace}/jobs/run/f/{path}"),
method: Cow::Borrowed("POST"),
path_params_schema: Some(serde_json::json!({
+11 -3
View File
@@ -12,7 +12,9 @@ use windmill_mcp::common::transform::transform_property_keys;
use windmill_mcp::common::types::{
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo,
};
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend, PathFilter};
use windmill_mcp::server::{
BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter,
};
use crate::auth::AuthCache;
use crate::db::ApiAuthed;
@@ -214,8 +216,11 @@ impl McpBackend for WindmillBackend {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value> {
let push_args = prepare_push_args(args);
let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = run_wait_result_script_by_path_internal(
self.db.clone(),
@@ -238,8 +243,11 @@ impl McpBackend for WindmillBackend {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value> {
let push_args = prepare_push_args(args);
let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = run_wait_result_flow_by_path_internal(
self.db.clone(),
+109 -9
View File
@@ -11,15 +11,19 @@ use serde_json::Value;
use sql_builder::prelude::*;
use windmill_common::auth::create_jwt_token;
use windmill_common::db::{Authed, UserDB};
use windmill_common::error::Error;
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
use windmill_common::triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind};
use windmill_common::utils::{query_elems_from_hub, StripPath};
use windmill_common::worker::to_raw_value;
use windmill_common::{DB, HUB_BASE_URL};
use windmill_mcp::server::{
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, PathFilter,
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, McpRequest, PathFilter,
};
use windmill_mcp::{HubResponse, HubScriptInfo, ItemSchema, ResourceInfo, ResourceType};
use windmill_trigger::trigger_helpers::{get_runnable_format, RunnableId};
use crate::args::build_headers;
use crate::db::ApiAuthed;
use crate::HTTP_CLIENT;
@@ -641,7 +645,7 @@ fn selects_endpoint_tool(caller_scopes: &[String], tool: &str) -> bool {
.is_ok_and(|config| config.endpoints.iter().any(|e| e == tool))
}
/// Create HTTP request with authentication
/// Create HTTP request with authentication.
pub async fn create_http_request(
method: &str,
url: &str,
@@ -702,17 +706,113 @@ pub async fn create_http_request(
.map_err(|e| ErrorData::internal_error(format!("Failed to execute request: {}", e), None))
}
/// Convert a JSON Value into PushArgsOwned for job execution
pub fn prepare_push_args(args: Value) -> windmill_queue::PushArgsOwned {
/// The `kind` an MCP-invoked runnable sees on its preprocessor event, alongside
/// `webhook`, `http` and the trigger kinds.
const MCP_TRIGGER_KEY: &str = "mcp";
/// A preprocessor's view of the MCP request that ran it. Mirrors the HTTP
/// trigger event: `body` is what the model sent, everything else describes the
/// call itself.
#[derive(serde::Serialize)]
struct McpPreprocessorEvent<'a> {
kind: &'a str,
body: Box<serde_json::value::RawValue>,
headers: HashMap<String, Box<serde_json::value::RawValue>>,
tool_name: &'a str,
}
/// Headers withheld from a preprocessor because they authenticate the connection.
///
/// Not a security boundary: a webhook preprocessor receives all three. Withheld
/// because nothing needs them yet, and releasing one later is additive while
/// withdrawing one after runnables read it is not.
const WITHHELD_FROM_PREPROCESSOR: &[&str] = &["authorization", "cookie", "proxy-authorization"];
/// Every header a preprocessor may see.
fn preprocessor_headers(
headers: &http::HeaderMap,
) -> HashMap<String, Box<serde_json::value::RawValue>> {
let mut selected = build_headers(headers, None, true);
selected.retain(|name, _| {
!WITHHELD_FROM_PREPROCESSOR
.iter()
.any(|withheld| withheld.eq_ignore_ascii_case(name))
});
selected
}
/// Build the job arguments for a script or flow run as an MCP tool.
///
/// Shaped by the runnable's own format: a preprocessor receives the request as
/// an event, and a runnable without one receives only what the model sent.
pub async fn prepare_push_args(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
args: Value,
request: &McpRequest<'_>,
) -> Result<windmill_queue::PushArgsOwned, Error> {
let mut main_args = HashMap::new();
if let Value::Object(map) = args {
let mut args_hash = HashMap::new();
for (k, v) in map {
args_hash.insert(k, to_raw_value(&v));
main_args.insert(k, to_raw_value(&v));
}
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
} else {
windmill_queue::PushArgsOwned::default()
}
let runnable_id = if is_flow {
RunnableId::from_flow_path(path)
} else {
// Resolves a `hub/<version_id>` path to the hub script on its own.
RunnableId::from_script_path(path)
};
// MCP is not one of the `TRIGGER_KIND` enum values and does not need to be:
// the per-kind arms of the no-preprocessor heuristic are payload-shape
// special cases for message triggers, and `Webhook` reaches the same generic
// arm MCP wants while sharing that kind's format cache.
let runnable_format = get_runnable_format(runnable_id, w_id, db, &TriggerKind::Webhook).await?;
Ok(match runnable_format {
// Without a preprocessor there is nowhere for a header to go that the
// model does not also write: its arguments *are* the runnable's
// parameters, so a header bound to one of them would be a value the model
// could set. The request is reachable through a preprocessor, where it
// arrives in a key of the event the model never fills.
RunnableFormat { has_preprocessor: false, .. } => {
windmill_queue::PushArgsOwned { args: main_args, extra: None }
}
RunnableFormat { has_preprocessor: true, version } => {
let headers = preprocessor_headers(request.headers);
match version {
RunnableFormatVersion::V2 => {
let event = McpPreprocessorEvent {
kind: MCP_TRIGGER_KEY,
body: to_raw_value(&main_args),
headers,
tool_name: request.tool_name,
};
windmill_queue::PushArgsOwned {
args: HashMap::from([("event".to_string(), to_raw_value(&event))]),
extra: None,
}
}
RunnableFormatVersion::V1 => windmill_queue::PushArgsOwned {
args: main_args,
extra: Some(HashMap::from([(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": MCP_TRIGGER_KEY,
MCP_TRIGGER_KEY: {
"headers": headers,
"tool_name": request.tool_name,
}
})),
)])),
},
}
}
})
}
/// Parse an HTTP response body into a JSON Value
+1
View File
@@ -618,6 +618,7 @@ pub(crate) async fn offboard_global_user(
sqlx::query!("DELETE FROM password WHERE email = $1", &email)
.execute(&mut *tx)
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email).await?;
sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email)
.execute(&mut *tx)
.await?;
+2 -1
View File
@@ -261,7 +261,8 @@ fn branch_sqls() -> Branches {
FROM draft d \
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred}) as draft_users"
WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND {typ_pred} \
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)) as draft_users"
)
};
-1
View File
@@ -99,7 +99,6 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
("configs", "Configs", "Configuration management", false),
("oauth", "OAuth", "OAuth management", false),
("ai", "AI", "AI feature management", false),
("ai_skills", "AI Skills", "AI skill management", false),
(
"ai_evals",
"AI Evals",
+23 -15
View File
@@ -146,6 +146,7 @@ async fn edit_copilot_config(
.await?;
let workspace_has_config = ai_config.has_providers();
let copilot_disabled = ai_config.copilot_disabled;
let instance_ai_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
@@ -158,7 +159,7 @@ async fn edit_copilot_config(
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.filter(|c| c.has_providers());
let effective_ai_config = if workspace_has_config {
let mut effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_config) = instance_config_with_providers {
instance_config
@@ -172,6 +173,7 @@ async fn edit_copilot_config(
} else {
AIConfig::default()
};
effective_ai_config.copilot_disabled = copilot_disabled;
Ok(Json(EditCopilotConfigResponse {
effective_ai_config,
@@ -207,6 +209,9 @@ async fn get_copilot_info(
))
})?;
let copilot_disabled = workspace_ai_config
.as_ref()
.is_some_and(|c| c.0.copilot_disabled);
let instance_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
@@ -215,20 +220,23 @@ async fn get_copilot_info(
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
// free-tier fallback, matching the proxy and edit_copilot_config paths.
.filter(|c| c.has_providers());
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) = instance_config {
Ok(Json(instance_config))
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
// carries a `free_tier` marker even once the user's grant is spent — with no
// providers, but telling the client *why* AI is off.
Ok(Json(free_config))
} else {
Ok(Json(AIConfig::default()))
}
let mut effective =
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
workspace_ai_config.0
} else if let Some(instance_config) = instance_config {
instance_config
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
// carries a `free_tier` marker even once the user's grant is spent — with no
// providers, but telling the client *why* AI is off.
free_config
} else {
AIConfig::default()
};
effective.copilot_disabled = copilot_disabled;
Ok(Json(effective))
}
#[cfg(feature = "enterprise")]
@@ -94,6 +94,9 @@ pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
/// empty leaves instance-group membership entirely to SCIM.
pub const SSO_GROUPS_CLAIM_SETTING: &str = "sso_groups_claim";
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
pub const DISABLE_HUB_SETTING: &str = "disable_hub";
@@ -108,6 +111,7 @@ pub const JWT_SECRET_SETTING: &str = "jwt_secret";
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";
pub const OTEL_SETTING: &str = "otel";
pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy";
pub const OTEL_TRACES_RETENTION_SECS_SETTING: &str = "otel_traces_retention_secs";
pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route";
pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route";
pub const SECRET_BACKEND_SETTING: &str = "secret_backend";
+57 -20
View File
@@ -148,43 +148,76 @@ pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
/// than an `i64`: `DateTime` subtraction panics past year 262143, and the `(<n> s)::interval`
/// the cleanup queries build overflows Postgres' microsecond field.
const MAX_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 365 * 100;
const MAX_RETENTION_SECS: i64 = 60 * 60 * 24 * 365 * 100;
/// Apply a configured service log retention, in seconds.
/// Clamp a configured retention window, in seconds, to one a cutoff can be built from.
///
/// The only way into [`SERVICE_LOG_RETENTION_SECS`], so an unusable value can never reach a
/// cutoff. The two unusable directions are not the same mistake and must not share a landing
/// point: too large still says "keep these for a very long time", so it is capped and the
/// intent survives, whereas falling back would delete logs the operator meant to keep. A
/// non-positive value has no such reading — every cutoff is `now - retention`, so it lands at
/// or after `now` and the next sweep expires the entire history, rows and object-storage files
/// alike. Unlike job retention there is no "keep forever" spelling here, so `0` — what an
/// operator types by analogy with it, and what the settings UI writes into a field that was
/// merely focused — falls back to the default.
pub fn set_service_log_retention_secs(configured: i64) {
let effective = if configured > MAX_SERVICE_LOG_RETENTION_SECS {
/// Shared by the retention windows that have no "keep forever" spelling, so that an unusable
/// value can never reach a cutoff. The two unusable directions are not the same mistake and must
/// not share a landing point: too large still says "keep these for a very long time", so it is
/// capped and the intent survives, whereas falling back would delete data the operator meant to
/// keep. A non-positive value has no such reading — every cutoff is `now - retention`, so it
/// lands at or after `now` and the next sweep expires the entire history. `0` is both what an
/// operator types by analogy with job retention, where it does mean keep forever, and what the
/// settings UI writes into a field that was merely focused, so it falls back to the default.
fn clamp_retention_secs(configured: i64, default: i64, what: &str) -> i64 {
if configured > MAX_RETENTION_SECS {
tracing::warn!(
"service log retention of {configured}s exceeds the maximum of \
{MAX_SERVICE_LOG_RETENTION_SECS}s, capping it there"
"{what} retention of {configured}s exceeds the maximum of {MAX_RETENTION_SECS}s, \
capping it there"
);
MAX_SERVICE_LOG_RETENTION_SECS
MAX_RETENTION_SECS
} else if configured >= 1 {
configured
} else {
tracing::warn!(
"service log retention of {configured}s would expire every service log, \
falling back to the default of {DEFAULT_SERVICE_LOG_RETENTION_SECS}s"
"{what} retention of {configured}s would expire the entire history, \
falling back to the default of {default}s"
);
DEFAULT_SERVICE_LOG_RETENTION_SECS
};
default
}
}
/// Apply a configured service log retention, in seconds.
///
/// The only way into [`SERVICE_LOG_RETENTION_SECS`]. Expiry reaches every copy of a log line:
/// the row, the file on disk, and the object-storage object.
pub fn set_service_log_retention_secs(configured: i64) {
let effective = clamp_retention_secs(
configured,
DEFAULT_SERVICE_LOG_RETENTION_SECS,
"service log",
);
SERVICE_LOG_RETENTION_SECS.store(effective, std::sync::atomic::Ordering::Relaxed);
}
/// Apply a configured OTEL trace retention, in seconds.
///
/// The only way into [`OTEL_TRACES_RETENTION_SECS`].
pub fn set_otel_traces_retention_secs(configured: i64) {
let effective = clamp_retention_secs(
configured,
DEFAULT_OTEL_TRACES_RETENTION_SECS,
"otel traces",
);
OTEL_TRACES_RETENTION_SECS.store(effective, std::sync::atomic::Ordering::Relaxed);
}
/// How long an HTTP request tracing span stays in `otel_traces`, in seconds.
///
/// Spans are keyed by the job they were captured for and read back by the job detail view, so
/// this is the outer bound on how far back that view can show a job's HTTP requests. It is
/// independent of job retention: a span can outlive its job, or be swept while the job remains.
pub fn otel_traces_retention_secs() -> i64 {
OTEL_TRACES_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
}
/// How long a service log line stays retrievable, in seconds.
///
/// The outer bound on everything service-log: the `log_file` rows, the raw files in object
@@ -423,6 +456,10 @@ lazy_static::lazy_static! {
/// would expire every service log cannot reach a cutoff. Read it with
/// [`service_log_retention_secs`].
static ref SERVICE_LOG_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_SERVICE_LOG_RETENTION_SECS);
/// Private on purpose, same as [`SERVICE_LOG_RETENTION_SECS`]:
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
/// only reader.
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
+40 -32
View File
@@ -358,31 +358,38 @@ pub async fn fetch_script_for_update<'a>(
.map_err(crate::error::Error::from)
}
pub struct ClonedScript {
pub old_script: NewScript,
pub new_hash: i64,
}
// TODO: What if dependency job fails, there is script with NULL in the lock
pub async fn clone_script<'c>(
path: &str,
w_id: &str,
/// Deploys the outcome of a relative-import relock as a new version of `head`, the path's live
/// version that the caller holds `FOR UPDATE`, and archives `head`. A `lock` of `None` records
/// a failed generation: the version carries `lock_error_logs` instead and runs keep resolving
/// to the last version that has a lock. A `modules` of `None` keeps the head's module locks.
///
/// Writes whatever `head` names and checks nothing: callers are responsible for having
/// established access to its workspace and path, as a dependency job's push already has.
///
/// `created_at` is stamped when the insert runs, not at transaction start. The row lock on
/// `head` is what orders one relock after another, and with `now()` a transaction that began
/// first but locked second commits a live child older than its archived parent, which every
/// "latest version" read then mis-orders.
pub async fn deploy_relocked_version(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
head: Script<ScriptRunnableSettingsHandle>,
deployment_message: Option<String>,
db: &DB,
) -> crate::error::Result<ClonedScript> {
let mut tx = db.begin().await?;
let s = if let Some(s) = fetch_script_for_update(path, w_id, &mut *tx).await? {
s
} else {
return Err(crate::error::Error::NotFound(format!(
"Non-archived script with path '{}' not found",
path
)));
};
lock: Option<&str>,
modules: Option<&std::collections::HashMap<String, ScriptModule>>,
lock_error_logs: Option<&str>,
) -> crate::error::Result<i64> {
let s = head;
let w_id = s.workspace_id.as_str();
let rs = runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, &mut *tx)
.await?;
let rs =
runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, &mut **tx)
.await?;
let (debouncing_settings, concurrency_settings) =
runnable_settings::prefetch_cached_tx(&rs, &mut tx).await?;
runnable_settings::prefetch_cached_tx(&rs, &mut *tx).await?;
// What the row stores is what the hash covers: the new module locks when there are any.
let modules = modules.cloned().or(s.modules);
let modules_json = modules.as_ref().map(serde_json::to_value).transpose()?;
let ns = NewScript {
path: s.path.clone(),
@@ -392,7 +399,7 @@ pub async fn clone_script<'c>(
content: s.content,
schema: s.schema,
is_template: s.is_template,
lock: None,
lock: lock.map(str::to_string),
language: s.language,
kind: Some(s.kind),
tag: s.tag,
@@ -424,7 +431,7 @@ pub async fn clone_script<'c>(
on_behalf_of: s.on_behalf_of,
preserve_on_behalf_of: None,
assets: s.assets,
modules: s.modules,
modules,
auto_parent: None,
labels: s.labels,
skip_draft_deletion: None,
@@ -433,7 +440,7 @@ pub async fn clone_script<'c>(
let new_hash = hash_script(&ns);
tracing::debug!(
"cloning script at path {} from '{}' to '{}'",
"deploying relocked version of script at path {} from '{}' to '{}'",
s.path,
*s.hash,
new_hash
@@ -446,17 +453,19 @@ pub async fn clone_script<'c>(
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \
lock_error_logs, created_at)
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \
content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels
codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \
$6::text, clock_timestamp()
FROM script WHERE hash = $2 AND workspace_id = $3;
", new_hash, s.hash.0, w_id).execute(&mut *tx).await?;
", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?;
// Archive base.
sqlx::query!(
@@ -464,9 +473,8 @@ pub async fn clone_script<'c>(
*s.hash,
w_id
)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
tx.commit().await?;
Ok(ClonedScript { old_script: ns, new_hash })
Ok(new_hash)
}
+65 -1
View File
@@ -245,7 +245,9 @@ async fn fetch_other_drafts_users(
// row: fall back to their instance-derived username (`password.username`), or
// their email when derivation is disabled. Else a real teammate's draft renders
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
// `username = None` (no `usr`/`password` match and `d.email` is NULL).
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
// why an owner that resolves to no name at all — an external JWT's subject has
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
@@ -261,6 +263,7 @@ async fn fetch_other_drafts_users(
AND d.path = $2
AND d.typ = $3
AND (d.email IS NULL OR d.email <> $4)
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
ORDER BY d.email NULLS LAST"#,
w_id,
path,
@@ -426,6 +429,67 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
}
}
/// Delete the drafts an address owns, across every workspace.
///
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
/// the account removal.
///
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
/// caller must already have authorized removing that account (superadmin, the account's own
/// holder, or SCIM).
pub async fn delete_drafts_of_email<'c>(
executor: impl sqlx::PgExecutor<'c>,
email: &str,
) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.execute(executor)
.await?;
Ok(())
}
/// Move the drafts an address owns onto its new address, for the same reason
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
///
/// The two addresses may each already hold a draft of the same item, since the destination can
/// belong to a principal with no account and so is not covered by the caller's "address is free"
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
/// a rename onto the same address returns early: every row would collide with itself and be
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
pub async fn rename_drafts_of_email(
conn: &mut sqlx::PgConnection,
old_email: &str,
new_email: &str,
) -> Result<()> {
if old_email == new_email {
return Ok(());
}
sqlx::query!(
"DELETE FROM draft dest
WHERE dest.email = $1
AND EXISTS (SELECT 1 FROM draft src
WHERE src.email = $2
AND src.workspace_id = dest.workspace_id
AND src.path = dest.path
AND src.typ = dest.typ)",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"UPDATE draft SET email = $1 WHERE email = $2",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
+2 -2
View File
@@ -175,7 +175,7 @@ pub enum ObjectType {
DatatableMigration,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28931/sync-script-to-git-repo-windmill";
/// Hub script that applies a repository's state back into a workspace
/// (the repo → Windmill / "pull" direction). Same script the UI runs from
@@ -183,7 +183,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28911/sync-script-to-git-repo
/// ignores the slug, so the slug is kept free of characters that would be
/// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened
/// reverse proxies reject as double-encoding when the client re-encodes it).
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28910/git-sync-init-repository-windmill";
pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28930/git-sync-init-repository-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
@@ -0,0 +1,28 @@
use sqlx::{Pool, Postgres};
use windmill_common::user_drafts::rename_drafts_of_email;
/// A rename onto the same address has to be a no-op: the helper clears a draft the destination
/// already holds at the same item, and every row would be its own destination. SCIM PATCH sends
/// `userName` unconditionally, so an IdP re-sending an unchanged one reaches this.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn renaming_onto_the_same_address_keeps_the_drafts(db: Pool<Postgres>) {
sqlx::query(
"INSERT INTO draft(workspace_id, path, typ, value, email) \
VALUES ('test-workspace', 'u/test-user/s', 'script', '{}'::json, 'test@windmill.dev')",
)
.execute(&db)
.await
.expect("failed to seed draft");
let mut conn = db.acquire().await.unwrap();
rename_drafts_of_email(&mut conn, "test@windmill.dev", "test@windmill.dev")
.await
.unwrap();
let kept: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM draft WHERE email = 'test@windmill.dev'")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(kept, 1);
}
+1
View File
@@ -26,4 +26,5 @@ tracing.workspace = true
lazy_static.workspace = true
chrono.workspace = true
itertools.workspace = true
futures.workspace = true
uuid.workspace = true
+36 -23
View File
@@ -1,6 +1,7 @@
pub mod ci_tests;
#[cfg(feature = "private")]
pub mod ci_tests_ee;
pub mod lock_hash;
pub mod scoped_dependency_map;
pub mod trigger_dependents;
pub mod workspace_dependencies;
@@ -127,6 +128,40 @@ pub fn extract_referenced_paths(
}
}
/// Re-records which paths `script_path` imports and what each one's lock hashes to right now.
/// That snapshot is what a later relock-skip check of this importer compares against, so it
/// has to move whenever the imports may have, whether or not the importer's own lock did.
///
/// Writes for any path in `w_id` and checks nothing: callers are responsible for having
/// established access to that workspace and script, as a dependency job's push already has.
pub async fn refresh_dependency_map(
db: &sqlx::Pool<sqlx::Postgres>,
w_id: &str,
script_path: &str,
parent_path: &Option<String>,
code: &str,
script_lang: &Option<ScriptLang>,
) -> error::Result<()> {
use scoped_dependency_map::ScopedDependencyMap;
let mut tx = db.begin().await?;
let mut dependency_map =
ScopedDependencyMap::fetch_maybe_rearranged(w_id, script_path, "script", parent_path, db)
.await?;
tx = dependency_map
.patch(
extract_referenced_paths(code, script_path, *script_lang),
// Ideally should be None, but due to current implementation will use empty string to represent None.
"".into(),
tx,
)
.await?;
dependency_map.dissolve(tx).await.commit().await?;
Ok(())
}
pub async fn process_relative_imports(
db: &sqlx::Pool<sqlx::Postgres>,
_job_id: Option<Uuid>,
@@ -144,29 +179,7 @@ pub async fn process_relative_imports(
use scoped_dependency_map::ScopedDependencyMap;
use trigger_dependents::trigger_dependents_to_recompute_dependencies;
// TODO: Should be moved into handle_dependency_job body to be more consistent with how flows and apps are handled
{
let mut tx = db.begin().await?;
let mut dependency_map = ScopedDependencyMap::fetch_maybe_rearranged(
&w_id,
script_path,
"script",
&parent_path,
db,
)
.await?;
tx = dependency_map
.patch(
extract_referenced_paths(&code, script_path, *script_lang),
// Ideally should be None, but due to current implementation will use empty string to represent None.
"".into(),
tx,
)
.await?;
dependency_map.dissolve(tx).await.commit().await?;
}
refresh_dependency_map(db, w_id, script_path, &parent_path, code, script_lang).await?;
{
let mut already_visited = args
+79
View File
@@ -0,0 +1,79 @@
use std::collections::HashMap;
use futures::TryStreamExt;
use sqlx::{Postgres, Transaction};
use windmill_common::error::Result;
use windmill_common::scripts::hash_script;
/// Records what the lock now at each path hashes to, which is one half of the comparison a relock
/// skip makes against what each importer resolved against.
///
/// Writes any path in `w_id` and checks nothing: callers are responsible for having established
/// the caller's access to that workspace. A path repeated in `entries` keeps its last hash.
///
/// Callers that write the lock itself in the same statement fold the upsert into that statement
/// instead; this is for the ones with nothing to fold it into.
pub async fn record_lock_hashes(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
entries: &[(String, i64)],
) -> Result<()> {
// Postgres rejects a whole statement that resolves a conflict on one key twice, so a path
// given more than once keeps its last hash, as it would if the two were written in order.
let mut deduped: HashMap<&str, i64> = HashMap::with_capacity(entries.len());
for (path, hash) in entries {
deduped.insert(path.as_str(), *hash);
}
if deduped.is_empty() {
return Ok(());
}
let (paths, hashes): (Vec<String>, Vec<i64>) = deduped
.into_iter()
.map(|(path, hash)| (path.to_string(), hash))
.unzip();
// Recording a hash a path already has would still cut a row version, and the no-op push this
// is reached from is the mode a git-sync of an unchanged workspace runs in.
sqlx::query!(
"INSERT INTO lock_hash (workspace_id, path, lockfile_hash)
SELECT $1, * FROM UNNEST($2::text[], $3::bigint[])
ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = EXCLUDED.lockfile_hash
WHERE lock_hash.lockfile_hash IS DISTINCT FROM EXCLUDED.lockfile_hash",
w_id,
&paths[..],
&hashes[..]
)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Records the hash of every live lock in `w_id`, for a workspace whose scripts arrived without
/// going through a deploy — a clone, which copies their locks verbatim and so would otherwise hold
/// none of the hashes describing them.
///
/// Carries the same caller obligation as [`record_lock_hashes`].
///
/// `script.lock` is unbounded and a workspace holds one per script, so the rows are streamed and
/// each lock is hashed and dropped before the next arrives; only the hashes accumulate.
pub async fn record_lock_hashes_for_workspace(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
) -> Result<()> {
let mut entries: Vec<(String, i64)> = Vec::new();
{
let mut rows = sqlx::query!(
"SELECT DISTINCT ON (path) path, lock FROM script
WHERE workspace_id = $1 AND NOT archived AND NOT deleted AND lock IS NOT NULL
ORDER BY path, created_at DESC",
w_id
)
.fetch(&mut **tx);
while let Some(row) = rows.try_next().await? {
if let Some(lock) = row.lock {
entries.push((row.path, hash_script(&lock)));
}
}
}
record_lock_hashes(tx, w_id, &entries).await
}
+7 -3
View File
@@ -101,7 +101,7 @@ fn apply_resource_enrichment(
let resources_count = resource_cache.len();
let description = match resource_type {
Some(rt) => format!(
"This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}",
"This is a resource named `{}` with the following description: `{}`.\nPass it as the bare string `$res:<path>` — the whole value of this argument, never an object wrapper like {{\"$res\": \"<path>\"}} and never a plain path.\n{}",
rt.name,
rt.description.as_deref().unwrap_or("No description"),
if resources_count == 0 {
@@ -138,7 +138,7 @@ fn apply_resource_enrichment(
)
})
.collect::<Vec<String>>()
.join("\\n");
.join("\n");
let prior_description = prop_map
.get("description")
.and_then(Value::as_str)
@@ -147,7 +147,7 @@ fn apply_resource_enrichment(
prop_map.insert(
"description".to_string(),
Value::String(format!(
"{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}",
"{}\nHere are the available resources, one per line as `title: $res:path`. The title is only a label; pass the `$res:path` part verbatim as this argument's value:\n{}",
prior_description, resources_description
)),
);
@@ -804,6 +804,10 @@ mod tests {
let desc = node["description"].as_str().unwrap();
assert!(desc.contains("c_aws_account"));
assert!(desc.contains("$res:f/platform/aws_dev"));
// MCP clients render this description verbatim, so the separators must be
// real newlines rather than the two-character escape.
assert!(desc.contains('\n'));
assert!(!desc.contains("\\n"));
}
#[test]
@@ -16,6 +16,14 @@ use crate::server::endpoints::EndpointTool;
/// Result type for backend operations using rmcp's ErrorData directly
pub type BackendResult<T> = Result<T, ErrorData>;
/// What the backend needs about the HTTP request a tool call arrived on, in order
/// to hand a runnable the headers of the call that triggered it.
pub struct McpRequest<'a> {
pub headers: &'a http::HeaderMap,
/// The MCP tool name the caller invoked, reported to preprocessors.
pub tool_name: &'a str,
}
/// How a script/flow listing is narrowed by path at the SQL layer, *before* the
/// `ITEMS_FETCH_MAX_LIMIT` cap applies.
///
@@ -157,6 +165,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value>;
/// Run a flow and wait for result
@@ -166,6 +175,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value>;
/// Call an endpoint tool (generated API endpoint)

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