mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
Merge branch 'main' into change-50d49bad
This commit is contained in:
+9
-13
@@ -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:*)",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.800.1"
|
||||
".": "1.803.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-29
@@ -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"
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-19
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4bc43a5e1c95cb8989962aeb1393a50df05437a2f1909ad5d303e2c2b89a0668"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
-35
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
Generated
+99
-97
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,4 +26,5 @@ tracing.workspace = true
|
||||
lazy_static.workspace = true
|
||||
chrono.workspace = true
|
||||
itertools.workspace = true
|
||||
futures.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user