From d29a5612fcd17eb4197468289e955a1209127cc1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 10:00:06 +0000 Subject: [PATCH 01/11] fix(indexer): tell admins when ingress routes search to wrong pod (#9274) * [ee] fix(indexer): tell admins when ingress routes search to wrong pod When the IndexReader is absent on the pod handling a search request but another pod is actively holding the indexer lock, the EE handler now returns a tailored error pointing at the ingress/load-balancer configuration instead of the generic "indexer not running" message. The indexer status endpoint reads the DB lock so it reports "running" from any pod, but search endpoints need the in-memory IndexReader that only exists on the lock holder. In multi-replica deployments this looks like the indexer is healthy but every search 404s. Companion: windmill-labs/windmill-ee-private#TBD Fixes WIN-1968. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 This commit updates the EE repository reference after PR #586 was merged in windmill-ee-private. Previous ee-repo-ref: 7dd43d1850813071cc18ba49ba090583e7321f4b New ee-repo-ref: eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...eb5c9c87999a877fcb7f23be75d991bba6e49.json | 22 +++++++++++++++++++ backend/ee-repo-ref.txt | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json diff --git a/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json b/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json new file mode 100644 index 0000000000..5321166c27 --- /dev/null +++ b/backend/.sqlx/query-9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT last_locked_at FROM concurrency_locks WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_locked_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 142ca727a7..3e4e2b57c5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -daffe7bb81cfcaca666c61de1ee838a44d60ebc2 +eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 From 1ba8ed8abd827313ce0f7728d9f84357417206ee Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 21 May 2026 12:44:15 +0200 Subject: [PATCH 02/11] feat(cli): add `wmill init prompts` and custom override slot (#9266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add `wmill init prompts` and custom override slot Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split Co-Authored-By: Claude Opus 4.7 (1M context) * feat(cli): dedupe claude skills via @-includes and add prompts freshness check Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(cli): drop migration-choice flags from `refresh prompts` Co-Authored-By: Claude Opus 4.7 (1M context) * docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli): write full skill content to .claude/, drop @-include wrapper Co-Authored-By: Claude Opus 4.7 (1M context) * feat(cli): reconcile CLAUDE.md the same way as AGENTS.md Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/init/init.ts | 47 +- cli/src/commands/refresh/prompts.ts | 182 ++++++++ cli/src/commands/refresh/refresh.ts | 8 + cli/src/guidance/core.ts | 100 +++- cli/src/guidance/freshness.ts | 169 +++++++ cli/src/guidance/freshness_gate.ts | 67 +++ cli/src/guidance/skills.gen.ts | 9 + cli/src/guidance/writer.ts | 240 ++++++++-- cli/src/main.ts | 12 + cli/test/guidance_writer_unit.test.ts | 430 ++++++++++++++++-- .../auto-generated/cli/cli-commands.md | 9 + system_prompts/auto-generated/prompts.ts | 9 + .../skills/cli-commands/SKILL.md | 9 + system_prompts/generate.py | 25 +- 14 files changed, 1185 insertions(+), 131 deletions(-) create mode 100644 cli/src/commands/refresh/prompts.ts create mode 100644 cli/src/commands/refresh/refresh.ts create mode 100644 cli/src/guidance/freshness.ts create mode 100644 cli/src/guidance/freshness_gate.ts diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index a77596ae9f..fd6b93380d 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -16,13 +16,8 @@ import { type Workspace, } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; -import { - WMILL_INIT_AI_AGENTS_SOURCE_ENV, - WMILL_INIT_AI_CLAUDE_SOURCE_ENV, - WMILL_INIT_AI_SKILLS_SOURCE_ENV, - writeAiGuidanceFiles, -} from "../../guidance/writer.ts"; import { generateCommentedTemplate } from "./template.ts"; +import { refreshPrompts } from "../refresh/prompts.ts"; export interface InitOptions { useDefault?: boolean; @@ -241,45 +236,7 @@ async function initAction(opts: InitOptions) { } } - // Read nonDottedPaths from config - let nonDottedPaths = true; // default for new inits - try { - const { readConfigFile } = await import("../../core/conf.ts"); - const config = await readConfigFile(); - nonDottedPaths = config.nonDottedPaths ?? true; - } catch { - // If config can't be read, use defaults - } - - // Create guidance files (AGENTS.md, CLAUDE.md, and agent skills) - try { - const guidanceResult = await writeAiGuidanceFiles({ - targetDir: ".", - nonDottedPaths, - overwriteProjectGuidance: false, - skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV], - agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], - claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], - }); - - if (guidanceResult.agentsWritten) { - log.info(colors.green("Created AGENTS.md")); - } - if (guidanceResult.claudeWritten) { - log.info(colors.green("Created CLAUDE.md")); - } - log.info( - colors.green( - `Created .claude/skills/ and .agents/skills/ with ${guidanceResult.skillCount} skills` - ) - ); - } catch (error) { - if (error instanceof Error) { - log.warn(`Could not create guidance files: ${error.message}`); - } else { - log.warn(`Could not create guidance files: ${error}`); - } - } + await refreshPrompts({ yes: opts.useDefault === true }); // Generate resource type namespace (only if a workspace was bound) if (didBindWorkspace && boundProfile) { diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts new file mode 100644 index 0000000000..3f933d3de2 --- /dev/null +++ b/cli/src/commands/refresh/prompts.ts @@ -0,0 +1,182 @@ +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Select } from "@cliffy/prompt/select"; +import * as log from "../../core/log.ts"; +import { + type AgentsMdMigration, + type ReconcileOutcome, + WMILL_INIT_AI_AGENTS_SOURCE_ENV, + WMILL_INIT_AI_CLAUDE_SOURCE_ENV, + WMILL_INIT_AI_SKILLS_SOURCE_ENV, + writeAiGuidanceFiles, +} from "../../guidance/writer.ts"; + +/** + * Programmatic entry point reused by `wmill init`. The init flow doesn't + * register the cliffy command itself — it imports and calls this directly so + * that prompt regeneration is part of every init. + */ +export async function refreshPrompts(opts: { + yes?: boolean; +}): Promise { + // Match `core/conf.ts`'s missing-key default (`?? false`) so legacy + // wmill.yaml files without the key don't drift from how sync renders + // paths. New projects get `true` via the wmill.yaml template, not via + // this fallback. + let nonDottedPaths = false; + try { + const { readConfigFile } = await import("../../core/conf.ts"); + const config = await readConfigFile(); + nonDottedPaths = config.nonDottedPaths ?? false; + } catch { + // If config can't be read, use the conservative default above. + } + + const interactive = process.stdin.isTTY && !opts.yes; + + try { + const result = await writeAiGuidanceFiles({ + targetDir: ".", + nonDottedPaths, + skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV], + agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], + claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], + resolveAgentsMdMigration: async () => { + if (!interactive) return "append"; + return await promptMigration(); + }, + }); + + log.info(colors.green("Refreshed AGENTS.cli.md")); + + reportReconciliation({ + file: "AGENTS.md", + includeLine: "@AGENTS.cli.md", + created: result.agentsCreated, + migration: result.agentsMigration, + }); + + reportReconciliation({ + file: "CLAUDE.md", + includeLine: "@AGENTS.md", + created: result.claudeCreated, + migration: result.claudeMigration, + }); + + log.info( + colors.green( + `Refreshed .claude/skills/ and .agents/skills/ with ${result.skillCount} skills` + ) + ); + log.info( + colors.gray( + "Project-specific instructions live in AGENTS.md (never overwritten unless you opt in)." + ) + ); + } catch (error) { + // Log first so the user sees what happened, then rethrow so `wmill + // refresh prompts` (and `wmill init`, which delegates here) exits + // non-zero. Silent swallowing would hide a broken refresh from CI. + if (error instanceof Error) { + log.error(`Could not refresh guidance files: ${error.message}`); + } else { + log.error(`Could not refresh guidance files: ${error}`); + } + throw error; + } +} + +function reportReconciliation(opts: { + file: string; + includeLine: string; + created: boolean; + migration: ReconcileOutcome; +}): void { + if (opts.created) { + log.info(colors.green(`Created ${opts.file} (user-owned)`)); + return; + } + switch (opts.migration) { + case "already-linked": + log.info( + colors.gray( + `${opts.file} already references ${opts.includeLine} — left as-is` + ) + ); + break; + case "append": + log.info( + colors.green(`Appended ${opts.includeLine} include to existing ${opts.file}`) + ); + break; + case "overwrite": + log.info(colors.yellow(`Overwrote ${opts.file} with managed skeleton`)); + break; + case "skip": + log.info( + colors.gray( + `${opts.file} left unchanged — wire \`${opts.includeLine}\` in manually when ready` + ) + ); + break; + case "not-applicable": + // unreachable when created is false, but keep exhaustive + break; + } +} + +async function promptMigration(): Promise { + log.info(""); + log.info( + colors.yellow( + "An existing AGENTS.md or CLAUDE.md was found that does not reference Windmill's managed guidance." + ) + ); + log.info( + colors.gray( + "Choose how to link the managed files in (we'll apply the same choice to AGENTS.md and CLAUDE.md):" + ) + ); + + const choice = await Select.prompt({ + message: "How should we handle the existing file(s)?", + options: [ + { + name: + "Append the include line " + + "(preserves your content — recommended if you have custom instructions)", + value: "append", + }, + { + name: + "Overwrite with the managed skeleton " + + "(replaces your content — pick if the file only had the default template)", + value: "overwrite", + }, + { + name: "Skip — leave the file alone; I'll wire it up manually", + value: "skip", + }, + ], + }); + + return choice as AgentsMdMigration; +} + +interface CommandOptions { + yes?: boolean; +} + +async function promptsAction(opts: CommandOptions): Promise { + await refreshPrompts({ yes: opts.yes === true }); +} + +const command = new Command() + .description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") + .option( + "--yes", + "Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include." + ) + .action(promptsAction as any); + +export default command; diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts new file mode 100644 index 0000000000..882284c428 --- /dev/null +++ b/cli/src/commands/refresh/refresh.ts @@ -0,0 +1,8 @@ +import { Command } from "@cliffy/command"; +import promptsCommand from "./prompts.ts"; + +const command = new Command() + .description("Refresh wmill-managed project files (AGENTS.cli.md and skills)") + .command("prompts", promptsCommand); + +export default command; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index fe7bc3011a..de65b33343 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -1,17 +1,55 @@ /** - * Core guidance content for AGENTS.md + * Core guidance content for the AGENTS files Windmill writes during init. * - * This module exports the template for the AGENTS.md file that provides - * AI agent instructions for working with Windmill projects. + * `wmill` writes two files: + * + * - `AGENTS.cli.md` — managed CLI / workspace guidance, refreshed by + * `wmill refresh prompts` (and the implicit refresh inside `wmill init`). + * - `AGENTS.md` — user-owned project entry point. The default skeleton + * references `AGENTS.cli.md` via an `@`-include so the managed content is + * pulled in automatically. */ +export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md"; + /** - * Generate the AGENTS.md content with the given skills reference. - * @param skillsReference - A formatted list of skills to include in the document - * @returns The complete AGENTS.md content + * Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md + * exists in the project. Everything below the `@AGENTS.cli.md` include is for + * the user to edit; nothing in this file is refreshed by `wmill`. */ -export function generateAgentsMdContent(skillsReference: string): string { - return `# Windmill AI Agent Instructions +export function generateAgentsMdSkeleton(): string { + return `# Project AI Agent Instructions + +This file is the entry point for AI agents working in this repository. It is +**user-owned** — \`wmill\` never overwrites it. Add your project-specific +guidance below the include line. + +The line below pulls in Windmill's managed CLI guidance (skills, deploy flow, +debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the +include line if you don't want the managed guidance in this project. + +${AGENTS_CLI_INCLUDE_LINE} + +## Project-specific instructions + + +`; +} + +/** + * Managed AGENTS.cli.md content. Rewritten by `wmill init` and + * `wmill refresh prompts` every time. + */ +export function generateAgentsCliMdContent(skillsReference: string): string { + return `# Windmill CLI Agent Instructions + +> Managed by \`wmill\`. This file is regenerated on \`wmill init\` and +> \`wmill refresh prompts\` — edit AGENTS.md (user-owned) for project-specific +> instructions instead. You are a helpful assistant that can help with Windmill scripts, flows, apps, and resources management. @@ -55,6 +93,50 @@ You MUST use the \`preview\` skill any time the user wants to see/open/visualize You MUST use the \`cli-commands\` skill to use the CLI. +## Running and previewing local changes + +Local previews exist for every entity type and don't deploy: + +- \`wmill script preview -d ''\` — run a local script. +- \`wmill flow preview -d ''\` — run a local flow.yaml. +- \`wmill app dev\` — live-reload dev server for raw apps. + +Argument shapes and per-language details live in the \`write-script-\`, \`write-flow\`, and \`raw-app\` skills. + +## Deploying + +There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit. + +### Detecting the setup + +Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy: + +- Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar. +- Cache the result for the rest of the session — don't re-scan on every deploy. + +If such a workflow exists → **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B). + +### Option A — \`git push\` (CI is wired to sync) + +The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it. + +1. \`git add\` + \`git commit\` the local changes. +2. \`git push\` to the branch the CI runs on. +3. The workflow deploys to the workspace. + +Only fall back to Option B if the user explicitly asks to bypass CI for this change (e.g. CI is broken, urgent hotfix), or if the workflow doesn't cover the current branch. + +### Option B — \`wmill sync push\` (no CI wiring) + +No CI workflow runs \`wmill sync push\` automatically, so deploy directly from the CLI: + +- \`wmill sync push --dry-run\` to preview. +- \`wmill sync push\` to apply. + +### In both cases + +Only deploy when the user explicitly asks to deploy, publish, push, or ship — not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy. + ## Debugging Jobs When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags. @@ -70,7 +152,7 @@ For flow failures, start with \`wmill job get \` to identify the failing ste ## Skills -For specific guidance, ALWAYS use the skills listed below. +For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`. ${skillsReference} `; diff --git a/cli/src/guidance/freshness.ts b/cli/src/guidance/freshness.ts new file mode 100644 index 0000000000..5104f7c0e6 --- /dev/null +++ b/cli/src/guidance/freshness.ts @@ -0,0 +1,169 @@ +/** + * Versioning + freshness check for the managed AGENTS.cli.md bundle. + * + * We embed a short hash of "what this CLI would write" into AGENTS.cli.md as + * an HTML comment. On every `wmill` command (with a few exceptions), we read + * the stored hash and compare against the current CLI's hash. Mismatch => + * one-line warning telling the user to `wmill refresh prompts`. + * + * The hash covers all inputs that affect the rendered bundle: the + * AGENTS.cli.md template, every skill body, schemas and schema mappings, and + * the nonDottedPaths setting. It is *not* tied to the CLI's package version, + * so non-prompt CLI releases don't produce false positives. + */ +import { createHash } from "node:crypto"; +import { stat } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { readTextFile } from "../utils/utils.ts"; +import { generateAgentsCliMdContent } from "./core.ts"; +import { + SCHEMAS, + SCHEMA_MAPPINGS, + SKILLS, + SKILL_CONTENT, +} from "./skills.gen.ts"; + +// Re-export from the gate module so existing callers (and tests) keep working. +// `shouldRunFreshnessCheck` lives there to avoid pulling skills.gen.ts (~360 KB) +// into main.ts's static import graph; main.ts now imports the gate directly +// and only `await import`s this file lazily. +import { shouldRunFreshnessCheck } from "./freshness_gate.ts"; +export { shouldRunFreshnessCheck }; + +export const PROMPTS_HASH_MARKER_PREFIX = "/; + +export function buildPromptsHashMarker(hash: string): string { + return `${PROMPTS_HASH_MARKER_PREFIX}${hash} -->`; +} + +export function extractPromptsHash(content: string): string | null { + const match = content.match(PROMPTS_HASH_REGEX); + return match ? match[1] : null; +} + +/** + * Insert the hash marker into rendered AGENTS.cli.md content. The marker + * goes on the line right after the title so it's easy to find and doesn't + * break the rendered Markdown structure. + */ +export function injectPromptsHashMarker( + content: string, + hash: string +): string { + const lines = content.split("\n"); + const marker = buildPromptsHashMarker(hash); + // Insert right after the first line if it's an H1 title; otherwise + // prepend so the marker is always near the top. + const insertAt = lines[0].startsWith("# ") ? 1 : 0; + lines.splice(insertAt, 0, marker); + return lines.join("\n"); +} + +/** + * Compute the hash for the rendered bundle. The hash is deterministic for a + * given (CLI bundle, nonDottedPaths) pair. + */ +export function currentPromptsHash(nonDottedPaths: boolean): string { + const hasher = createHash("sha256"); + + // Template structure (without the skills reference — that's hashed + // separately from the SKILLS metadata). + hasher.update("template:"); + hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__")); + + // Skill metadata (names + descriptions) — fed into the skills reference + // line in AGENTS.cli.md and the wrapper frontmatter. + hasher.update("\nskills:"); + hasher.update(JSON.stringify(SKILLS)); + + // Skill bodies — what actually lands in .agents/skills//SKILL.md. + // Sort entries for stable ordering. + hasher.update("\nbodies:"); + for (const [name, content] of Object.entries(SKILL_CONTENT).sort()) { + hasher.update("\n"); + hasher.update(name); + hasher.update("\n"); + hasher.update(content); + } + + // Schemas + their mappings — embedded inside specific skills. + hasher.update("\nschemas:"); + hasher.update(JSON.stringify(SCHEMAS)); + hasher.update("\nmappings:"); + hasher.update(JSON.stringify(SCHEMA_MAPPINGS)); + + // Path-style setting — controls __flow vs .flow rendering in skill bodies. + hasher.update("\nnonDotted:"); + hasher.update(String(nonDottedPaths)); + + return hasher.digest("hex").slice(0, 12); +} + +/** + * Read AGENTS.cli.md in the current working directory, compare its embedded + * hash to the current CLI's hash, and print a one-line warning if they + * differ. Silent on every other code path (no AGENTS.cli.md, no marker, + * matching hash, IO error, …) so it never gets in the user's way. + */ +export async function warnIfPromptsStale(opts?: { + cwd?: string; + nonDottedPaths?: boolean; + argv?: readonly string[]; +}): Promise { + if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return; + + const cwd = opts?.cwd ?? process.cwd(); + const path = `${cwd}/AGENTS.cli.md`; + + if (!(await stat(path).catch(() => null))) return; + + let content: string; + try { + content = await readTextFile(path); + } catch { + return; + } + + const stored = extractPromptsHash(content); + if (!stored) { + // Older AGENTS.cli.md without a marker. Warn so the user re-runs + // refresh and picks up the new format. + emitWarning( + "Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker." + ); + return; + } + + let nonDottedPaths = opts?.nonDottedPaths; + if (nonDottedPaths === undefined) { + try { + const { readConfigFile } = await import("../core/conf.ts"); + const config = await readConfigFile(); + // Match `core/conf.ts`'s missing-key default (`?? false`); otherwise + // legacy wmill.yaml files without the key trip a permanent freshness + // warning even though the prompts are objectively up to date. + nonDottedPaths = config.nonDottedPaths ?? false; + } catch { + nonDottedPaths = false; + } + } + + const current = currentPromptsHash(nonDottedPaths); + if (stored !== current) { + emitWarning( + "Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh." + ); + } +} + +/** + * Send the freshness warning to **stderr** so it never contaminates a + * downstream pipe (e.g. `wmill job result | jq`). The rest of the CLI + * uses `log.warn` which writes to stdout — that's wrong for an always-on + * notification like this one, but we don't want to fix `log.warn` globally + * in this PR. + */ +function emitWarning(message: string): void { + process.stderr.write(`${colors.yellow(message)}\n`); +} diff --git a/cli/src/guidance/freshness_gate.ts b/cli/src/guidance/freshness_gate.ts new file mode 100644 index 0000000000..0b5f7c8a17 --- /dev/null +++ b/cli/src/guidance/freshness_gate.ts @@ -0,0 +1,67 @@ +/** + * Argv-only gate for the prompts freshness check. Kept in its own module so + * `main.ts` can import it without pulling in the heavy `skills.gen.ts` + * bundle (~360 KB) on every `wmill` invocation. The full check (which does + * touch the bundle) lives in `./freshness.ts` and is loaded lazily after + * this gate returns `true`. + */ + +/** + * Subcommands where a freshness warning is noise (the user is either fixing + * it, asking for help, or doing something orthogonal). + */ +const SKIP_FRESHNESS_FOR_SUBCOMMANDS = new Set([ + "init", + "refresh", + "completions", + "upgrade", +]); + +/** + * Cliffy global options that consume the *next* argv element as their value. + * Must be kept in sync with the option declarations on the top-level + * `command` in `cli/src/main.ts`. + */ +const VALUE_GLOBAL_OPTS = new Set([ + "--workspace", + "--token", + "--base-url", + "--config-dir", +]); + +/** + * Returns `true` if the freshness check should run for this invocation. + * + * Bypasses: + * - bare `wmill` (no subcommand → shows help) + * - `--help`, `-h`, `--version`, `-V` anywhere in the args + * - subcommands in {init, refresh, completions, upgrade} + * + * Handles cliffy global options that take a value (`--workspace foo`, + * `--token tok`, `--base-url https://…`, `--config-dir /etc/wmill`) by + * skipping their value when scanning for the first positional argument. + * Without that, `wmill --workspace prod refresh prompts` would misread + * `"prod"` as the subcommand and fire the warning during the very command + * meant to fix it. + */ +export function shouldRunFreshnessCheck(argv: readonly string[]): boolean { + const args = argv.slice(2); // strip node + script + if (args.length === 0) return false; + if (args.includes("--help") || args.includes("-h")) return false; + if (args.includes("--version") || args.includes("-V")) return false; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + if (VALUE_GLOBAL_OPTS.has(arg)) { + i += 2; // skip flag + its value + continue; + } + if (arg.startsWith("-")) { + i += 1; // flag with no value + continue; + } + return !SKIP_FRESHNESS_FOR_SUBCOMMANDS.has(arg); + } + return false; +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8e8e447aed..de1b8bcdc6 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -7001,6 +7001,15 @@ List all queues with their metrics - \`--instance [instance]\` - Name of the instance to push to, override the active instance - \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index 8d6f4187b8..fcaae0671a 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -1,7 +1,15 @@ import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises"; import { readTextFile } from "../utils/utils.ts"; import { join } from "node:path"; -import { generateAgentsMdContent } from "./core.ts"; +import { + AGENTS_CLI_INCLUDE_LINE, + generateAgentsCliMdContent, + generateAgentsMdSkeleton, +} from "./core.ts"; +import { + currentPromptsHash, + injectPromptsHashMarker, +} from "./freshness.ts"; import { SCHEMAS, SCHEMA_MAPPINGS, @@ -14,18 +22,47 @@ type ResolvedSkillMetadata = SkillMetadata & { directoryName: string; }; +/** + * How to reconcile an existing user-owned guidance file (AGENTS.md or + * CLAUDE.md) that doesn't reference the managed file below it + * (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md). + * + * - `append`: leave the file as-is and append the include line. + * - `overwrite`: replace the file with the managed skeleton. + * - `skip`: leave the file alone. The managed downstream file is still + * written/refreshed, but no link to it — the user is expected to wire it + * manually later. + */ +export type AgentsMdMigration = "append" | "overwrite" | "skip"; + +export type ReconcileOutcome = + | AgentsMdMigration + | "already-linked" + | "not-applicable"; + export interface WriteAiGuidanceOptions { targetDir: string; nonDottedPaths?: boolean; - overwriteProjectGuidance?: boolean; + /** Skill source override (testing / source-of-truth bundling). */ skillsSourcePath?: string; + /** AGENTS.cli.md source override (testing). */ agentsSourcePath?: string; + /** CLAUDE.md source override (testing). */ claudeSourcePath?: string; + /** + * Optional resolver invoked when an existing AGENTS.md lacks an + * `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if + * omitted, the writer defaults to `append` (non-destructive). + */ + resolveAgentsMdMigration?: () => Promise; } export interface WriteAiGuidanceResult { - agentsWritten: boolean; - claudeWritten: boolean; + agentsCliWritten: boolean; + agentsCreated: boolean; + agentsMigration: ReconcileOutcome; + claudeCreated: boolean; + claudeMigration: ReconcileOutcome; skillCount: number; } @@ -34,32 +71,73 @@ export const WMILL_INIT_AI_AGENTS_SOURCE_ENV = "WMILL_INIT_AI_AGENTS_SOURCE"; export const WMILL_INIT_AI_CLAUDE_SOURCE_ENV = "WMILL_INIT_AI_CLAUDE_SOURCE"; const CLAUDE_MD_DEFAULT = "Instructions are in @AGENTS.md\n"; -const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const; +const CLAUDE_MD_INCLUDE_LINE = "@AGENTS.md"; + +/** + * Both `.agents/skills/` (read by Codex, Pi) and `.claude/skills/` (read by + * Claude Code) receive the full skill content. We can't use `@` to + * deduplicate because Claude's skill loader reads SKILL.md as-is — it does + * not expand `@` references inside skill bodies (those work only in + * AGENTS.md / CLAUDE.md). + */ +const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const; export async function writeAiGuidanceFiles( options: WriteAiGuidanceOptions ): Promise { - const nonDottedPaths = options.nonDottedPaths ?? true; + // Match `core/conf.ts`'s missing-key default — if a legacy wmill.yaml + // omits `nonDottedPaths`, sync treats it as `false`, so we must too or + // the freshness hash will be permanently out of sync with the rest of + // the CLI's view of the project. + const nonDottedPaths = options.nonDottedPaths ?? false; const skillMetadata = options.skillsSourcePath ? await readSkillMetadataFromDirectory(options.skillsSourcePath) : getGeneratedSkillMetadata(); - const agentsWritten = await writeProjectGuidanceFile({ - targetPath: join(options.targetDir, "AGENTS.md"), - overwrite: options.overwriteProjectGuidance ?? false, - content: - options.agentsSourcePath != null - ? await readTextFile(options.agentsSourcePath) - : generateAgentsMdContent(buildSkillsReference(skillMetadata)), + // AGENTS.cli.md — always (re)written, this is the managed file. + // We embed a content-hash marker so other `wmill` commands can detect a + // stale bundle and prompt the user to `wmill refresh prompts`. + const rawAgentsCliContent = + options.agentsSourcePath != null + ? await readTextFile(options.agentsSourcePath) + : generateAgentsCliMdContent(buildSkillsReference(skillMetadata)); + const agentsCliContent = injectPromptsHashMarker( + rawAgentsCliContent, + currentPromptsHash(nonDottedPaths) + ); + const agentsCliPath = join(options.targetDir, "AGENTS.cli.md"); + await writeFile(agentsCliPath, agentsCliContent, "utf8"); + const agentsCliWritten = true; + + // Cache the user's first migration answer and reuse it for every file + // that needs reconciling in this run — there's never a good reason to ask + // the same question twice in a row. + const resolveMigration = cacheOnce(options.resolveAgentsMdMigration); + + // AGENTS.md — user-owned. Three paths: + // 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md). + // 2. exists and already references @AGENTS.cli.md → leave alone. + // 3. exists but doesn't reference @AGENTS.cli.md → ask caller via + // resolveMigration (defaults to append). + const agentsMdResult = await reconcileIncludingFile({ + path: join(options.targetDir, "AGENTS.md"), + includeLine: AGENTS_CLI_INCLUDE_LINE, + skeleton: generateAgentsMdSkeleton(), + resolveMigration, }); - const claudeWritten = await writeProjectGuidanceFile({ - targetPath: join(options.targetDir, "CLAUDE.md"), - overwrite: options.overwriteProjectGuidance ?? false, - content: - options.claudeSourcePath != null - ? await readTextFile(options.claudeSourcePath) - : CLAUDE_MD_DEFAULT, + // CLAUDE.md — user-owned wrapper that points at @AGENTS.md. Same three-way + // reconciliation: create if missing, leave alone if it already references + // AGENTS.md, otherwise ask via resolveMigration. + const claudeSkeleton = + options.claudeSourcePath != null + ? await readTextFile(options.claudeSourcePath) + : CLAUDE_MD_DEFAULT; + const claudeMdResult = await reconcileIncludingFile({ + path: join(options.targetDir, "CLAUDE.md"), + includeLine: CLAUDE_MD_INCLUDE_LINE, + skeleton: claudeSkeleton, + resolveMigration, }); if (options.skillsSourcePath) { @@ -69,17 +147,87 @@ export async function writeAiGuidanceFiles( } return { - agentsWritten, - claudeWritten, + agentsCliWritten, + agentsCreated: agentsMdResult.created, + agentsMigration: agentsMdResult.migration, + claudeCreated: claudeMdResult.created, + claudeMigration: claudeMdResult.migration, skillCount: skillMetadata.length, }; } +function cacheOnce( + resolver: (() => Promise) | undefined +): (() => Promise) | undefined { + if (!resolver) return undefined; + let cached: AgentsMdMigration | null = null; + return async () => { + if (cached !== null) return cached; + cached = await resolver(); + return cached; + }; +} + +async function reconcileIncludingFile(options: { + path: string; + includeLine: string; + skeleton: string; + resolveMigration?: () => Promise; +}): Promise<{ created: boolean; migration: ReconcileOutcome }> { + const exists = (await stat(options.path).catch(() => null)) != null; + if (!exists) { + await writeFile(options.path, options.skeleton, "utf8"); + return { created: true, migration: "not-applicable" }; + } + + const existing = await readTextFile(options.path); + if (referencesIncludeLine(existing, options.includeLine)) { + return { created: false, migration: "already-linked" }; + } + + const choice = options.resolveMigration + ? await options.resolveMigration() + : "append"; + + if (choice === "skip") { + return { created: false, migration: "skip" }; + } + + if (choice === "overwrite") { + await writeFile(options.path, options.skeleton, "utf8"); + return { created: false, migration: "overwrite" }; + } + + // append — add the include at the end, leaving existing content untouched. + const appended = existing.endsWith("\n") + ? `${existing}\n${options.includeLine}\n` + : `${existing}\n\n${options.includeLine}\n`; + await writeFile(options.path, appended, "utf8"); + return { created: false, migration: "append" }; +} + +function referencesIncludeLine(content: string, includeLine: string): boolean { + // Match only when the include sits on a line by itself (allowing leading + // and trailing whitespace). Earlier we split on `\s+`, but that + // false-positives on commented-out includes like `` + // where the middle token equals the include. CRLF is handled by the + // `\r?\n` split. + for (const line of content.split(/\r?\n/)) { + if (line.trim() === includeLine) { + return true; + } + } + return false; +} + function buildSkillsReference( skills: Pick[] ): string { return skills - .map((skill) => `- \`.claude/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`) + .map( + (skill) => + `- \`.agents/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}` + ) .join("\n"); } @@ -89,7 +237,9 @@ async function copySkillsFromSource( ): Promise { const skillsDirs = await ensureSkillsDirectories(targetDir); await Promise.all( - skillsDirs.map((skillsDir) => copyDirectoryContents(skillsSourcePath, skillsDir)) + skillsDirs.map((skillsDir) => + copyDirectoryContents(skillsSourcePath, skillsDir) + ) ); return await readSkillMetadataFromDirectory(skillsDirs[0]); } @@ -137,7 +287,10 @@ async function ensureSkillsDirectories(targetDir: string): Promise { return skillsDirs; } -async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise { +async function copyDirectoryContents( + sourceDir: string, + targetDir: string +): Promise { const entries = await readdir(sourceDir, { withFileTypes: true }); await Promise.all( @@ -150,7 +303,10 @@ async function copyDirectoryContents(sourceDir: string, targetDir: string): Prom ); } -function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean): string { +function renderGeneratedSkillContent( + skillName: string, + nonDottedPaths: boolean +): string { let skillContent = SKILL_CONTENT[skillName]; if (!skillContent) { throw new Error(`Missing generated skill content for ${skillName}`); @@ -187,7 +343,11 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean) if (!schemaYaml) { return null; } - return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern); + return formatSchemaForMarkdown( + schemaYaml, + mapping.name, + mapping.filePattern + ); }) .filter((entry): entry is string => entry !== null); @@ -198,11 +358,15 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean) return `${skillContent}\n\n${schemaDocs.join("\n\n")}`; } -async function readSkillMetadataFromDirectory(skillsDir: string): Promise { +async function readSkillMetadataFromDirectory( + skillsDir: string +): Promise { const entries = await readdir(skillsDir, { withFileTypes: true }); const skills: ResolvedSkillMetadata[] = []; - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + for (const entry of entries.sort((left, right) => + left.name.localeCompare(right.name) + )) { if (!entry.isDirectory()) { continue; } @@ -219,7 +383,10 @@ async function readSkillMetadataFromDirectory(skillsDir: string): Promise { - if (!options.overwrite && (await stat(options.targetPath).catch(() => null))) { - return false; - } - - await writeFile(options.targetPath, options.content, "utf8"); - return true; -} - function formatSchemaForMarkdown( schemaYaml: string, schemaName: string, diff --git a/cli/src/main.ts b/cli/src/main.ts index dad66d351f..a03db55d0d 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -40,6 +40,8 @@ import workers from "./commands/workers/workers.ts"; import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; +import refresh from "./commands/refresh/refresh.ts"; +import { shouldRunFreshnessCheck } from "./guidance/freshness_gate.ts"; import jobs from "./commands/jobs/jobs.ts"; import job from "./commands/job/job.ts"; import group from "./commands/group/group.ts"; @@ -175,6 +177,7 @@ const command = new Command() }, }) .command("init", init) + .command("refresh", refresh) .command("app", app) .command("flow", flow) .command("script", script) @@ -291,6 +294,15 @@ async function main() { await detectAuthGatewayChallenge(response); return response; }); + + // Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle. + // The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts + // bundle stays out of the import graph for help/version/init/refresh/etc. + if (shouldRunFreshnessCheck(process.argv)) { + const { warnIfPromptsStale } = await import("./guidance/freshness.ts"); + await warnIfPromptsStale({ argv: process.argv }).catch(() => {}); + } + await command.parse(args); } catch (e) { if (e && typeof e === "object" && "name" in e && e.name === "ApiError") { diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index 4399639905..fa3a91d641 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -3,8 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeAiGuidanceFiles } from "../src/guidance/writer.ts"; +import { + currentPromptsHash, + extractPromptsHash, + injectPromptsHashMarker, + shouldRunFreshnessCheck, + warnIfPromptsStale, +} from "../src/guidance/freshness.ts"; -const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const; +const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const; async function withTempDir(fn: (tempDir: string) => Promise): Promise { const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_")); @@ -26,7 +33,7 @@ async function writeSkill( return skillPath; } -describe("writeAiGuidanceFiles", () => { +describe("writeAiGuidanceFiles — skills", () => { test("preserves custom skills when refreshing generated guidance", async () => { await withTempDir(async (tempDir) => { const skillsDirs = SKILL_TARGET_ROOTS.map((root) => @@ -52,19 +59,21 @@ Preserve me. ) ); - await writeAiGuidanceFiles({ - targetDir: tempDir, - overwriteProjectGuidance: false, - }); + await writeAiGuidanceFiles({ targetDir: tempDir }); + // Custom skills survive on every side, untouched. for (const customSkillPath of customSkillPaths) { expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent); } + // Both `.agents/skills/` and `.claude/skills/` hold the same full + // canonical content. (Claude's skill loader doesn't expand `@` + // references inside SKILL.md, so we can't dedupe via `@`-include.) for (const generatedSkillPath of generatedSkillPaths) { const generatedSkillContent = await readFile(generatedSkillPath, "utf8"); expect(generatedSkillContent).not.toBe(staleGeneratedContent); expect(generatedSkillContent).toContain("name: write-flow"); + expect(generatedSkillContent).not.toContain("@../../../"); } }); }); @@ -101,7 +110,7 @@ Copied from source bundle. writeSkill(skillsDir, "custom-skill", customSkillContent) ) ); - const existingGeneratedSkillPaths = await Promise.all( + await Promise.all( skillsDirs.map((skillsDir) => writeSkill(skillsDir, "write-flow", "old content") ) @@ -113,25 +122,28 @@ Copied from source bundle. await writeAiGuidanceFiles({ targetDir: tempDir, - overwriteProjectGuidance: false, skillsSourcePath: sourceSkillsDir, }); + // Custom skills survive untouched on every side. for (const customSkillPath of customSkillPaths) { expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent); } - for (const existingGeneratedSkillPath of existingGeneratedSkillPaths) { - expect(await readFile(existingGeneratedSkillPath, "utf8")).toBe(sourceSkillContent); - } - for (const skillsDir of skillsDirs) { - expect(await readFile(join(skillsDir, "bundle-only", "SKILL.md"), "utf8")).toBe( - bundleOnlySkillContent - ); + + // Source bundle is copied verbatim into both `.agents/skills/` and + // `.claude/skills/`. No `@`-include wrapping. + for (const root of SKILL_TARGET_ROOTS) { + expect( + await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8") + ).toBe(sourceSkillContent); + expect( + await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8") + ).toBe(bundleOnlySkillContent); } }); }); - test("builds AGENTS skill references from copied directory names", async () => { + test("AGENTS.cli.md gets the skills reference from copied directory names", async () => { await withTempDir(async (tempDir) => { const sourceSkillsDir = join(tempDir, "source-skills"); await writeSkill( @@ -148,29 +160,393 @@ Copied from source bundle. await writeAiGuidanceFiles({ targetDir: tempDir, - overwriteProjectGuidance: false, skillsSourcePath: sourceSkillsDir, }); - const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); - expect(agentsMd).toContain(".claude/skills/custom-folder/SKILL.md"); - expect(agentsMd).not.toContain(".claude/skills/write-flow/SKILL.md"); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md"); + expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md"); + // The skill reference points at the .agents/ tree — not .claude/ — + // so the path is meaningful to Codex/Pi as well as Claude. + expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md"); }); }); - test("writes AGENTS.md and CLAUDE.md even if skills creation fails", async () => { + test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => { await withTempDir(async (tempDir) => { + // Create a file at .claude so mkdir of .claude/skills throws. await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8"); await expect( - writeAiGuidanceFiles({ - targetDir: tempDir, - overwriteProjectGuidance: false, - }) + writeAiGuidanceFiles({ targetDir: tempDir }) ).rejects.toThrow(); - expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(".claude/skills/"); - expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain("@AGENTS.md"); + expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain( + ".agents/skills/" + ); + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain( + "@AGENTS.cli.md" + ); + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain( + "@AGENTS.md" + ); + }); + }); +}); + +describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => { + test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => { + await withTempDir(async (tempDir) => { + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsCreated).toBe(true); + expect(result.agentsMigration).toBe("not-applicable"); + + const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(agentsMd).toContain("@AGENTS.cli.md"); + }); + }); + + test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => { + await withTempDir(async (tempDir) => { + const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("already-linked"); + + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original); + }); + }); + + test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Existing custom AGENTS.md\n\nproject rules here.\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); + + test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Some old AGENTS.md to be replaced\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "overwrite", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("overwrite"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).not.toBe(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); + + test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Hand-managed AGENTS.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "skip", + }); + expect(result.agentsCreated).toBe(false); + expect(result.agentsMigration).toBe("skip"); + + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original); + }); + }); + + test("defaults to 'append' when the resolver is not provided", async () => { + await withTempDir(async (tempDir) => { + const original = "# AGENTS.md\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.cli.md"); + }); + }); +}); + +describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => { + test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => { + await withTempDir(async (tempDir) => { + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.claudeCreated).toBe(true); + expect(result.claudeMigration).toBe("not-applicable"); + + const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8"); + expect(claudeMd).toContain("@AGENTS.md"); + }); + }); + + test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => { + await withTempDir(async (tempDir) => { + const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("already-linked"); + + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original); + }); + }); + + test("appends @AGENTS.md when the resolver returns 'append'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("append"); + + const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8"); + expect(updated).toStartWith(original); + expect(updated).toContain("@AGENTS.md"); + }); + }); + + test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Some old CLAUDE.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "overwrite", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("overwrite"); + + expect( + await readFile(join(tempDir, "CLAUDE.md"), "utf8") + ).not.toBe(original); + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain( + "@AGENTS.md" + ); + }); + }); + + test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => { + await withTempDir(async (tempDir) => { + const original = "# Hand-managed CLAUDE.md\n"; + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "skip", + }); + expect(result.claudeCreated).toBe(false); + expect(result.claudeMigration).toBe("skip"); + + expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original); + }); + }); + + test("resolver is invoked at most once even if both files need it", async () => { + await withTempDir(async (tempDir) => { + const original = "# old\n"; + await writeFile(join(tempDir, "AGENTS.md"), original, "utf8"); + await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8"); + + let resolverCalls = 0; + await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => { + resolverCalls += 1; + return "append"; + }, + }); + expect(resolverCalls).toBe(1); + }); + }); +}); + +describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => { + test.each([ + ["bare line", "@AGENTS.cli.md"], + ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], + ["leading whitespace then include", " @AGENTS.cli.md\n"], + ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + ])("treats %s as a reference (no append)", async (_label, content) => { + await withTempDir(async (tempDir) => { + await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); + const result = await writeAiGuidanceFiles({ targetDir: tempDir }); + expect(result.agentsMigration).toBe("already-linked"); + expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content); + }); + }); + + test.each([ + ["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"], + ["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"], + ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], + ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], + ["commented-out include", ""], + ["blockquoted include", "> @AGENTS.cli.md"], + ])("does not treat %s as a reference (append happens)", async (_label, content) => { + await withTempDir(async (tempDir) => { + await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); + const result = await writeAiGuidanceFiles({ + targetDir: tempDir, + resolveAgentsMdMigration: async () => "append", + }); + expect(result.agentsMigration).toBe("append"); + }); + }); +}); + +describe("prompts freshness — hash marker", () => { + test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => { + await withTempDir(async (tempDir) => { + await writeAiGuidanceFiles({ targetDir: tempDir }); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + const hash = extractPromptsHash(agentsCli); + expect(hash).not.toBeNull(); + expect(hash).toMatch(/^[0-9a-f]{12}$/); + }); + }); + + test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => { + await withTempDir(async (tempDir) => { + // writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching + // core/conf.ts's missing-key default). + await writeAiGuidanceFiles({ targetDir: tempDir }); + const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8"); + expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false)); + }); + }); + + test("nonDottedPaths setting changes the hash", () => { + expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false)); + }); + + test("injectPromptsHashMarker places the marker after the H1 title", () => { + const input = "# Title\n\nbody line\n"; + const out = injectPromptsHashMarker(input, "abc123def456"); + const lines = out.split("\n"); + expect(lines[0]).toBe("# Title"); + expect(lines[1]).toBe(""); + expect(lines[2]).toBe(""); + expect(lines[3]).toBe("body line"); + }); + + test("injectPromptsHashMarker prepends when there's no H1", () => { + const input = "no heading\nrest\n"; + const out = injectPromptsHashMarker(input, "abc123def456"); + expect(out).toStartWith(""); + }); + + test("extractPromptsHash returns null when no marker is present", () => { + expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull(); + expect(extractPromptsHash("")).toBeNull(); + }); +}); + +describe("prompts freshness — shouldRunFreshnessCheck", () => { + // Each input matches process.argv shape: [node, script, ...args]. + test.each<[string, string[], boolean]>([ + ["empty argv", ["node", "wmill"], false], + ["wmill --help", ["node", "wmill", "--help"], false], + ["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false], + ["wmill --version", ["node", "wmill", "--version"], false], + ["wmill init", ["node", "wmill", "init"], false], + ["wmill init prompts", ["node", "wmill", "init", "prompts"], false], + ["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false], + ["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false], + ["wmill upgrade", ["node", "wmill", "upgrade"], false], + ["wmill sync push", ["node", "wmill", "sync", "push"], true], + ["wmill flow run", ["node", "wmill", "flow", "run"], true], + ["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true], + // Value-taking global options must skip their value when locating the + // first subcommand. Otherwise `wmill --workspace prod refresh prompts` + // would misread `"prod"` as the subcommand and trip the warning during + // the very command that's meant to fix it. + ["wmill --workspace prod refresh prompts", + ["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false], + ["wmill --token tok sync push", + ["node", "wmill", "--token", "tok", "sync", "push"], true], + ["wmill --base-url u --workspace w init", + ["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false], + ["wmill --config-dir /etc/wmill init prompts", + ["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false], + ])("%s → %s", (_label, argv, expected) => { + expect(shouldRunFreshnessCheck(argv)).toBe(expected); + }); +}); + +describe("prompts freshness — additional invariants", () => { + test("currentPromptsHash is deterministic across invocations in the same process", () => { + const h1 = currentPromptsHash(true); + const h2 = currentPromptsHash(true); + const h3 = currentPromptsHash(false); + const h4 = currentPromptsHash(false); + expect(h1).toBe(h2); + expect(h3).toBe(h4); + }); + + test("warnIfPromptsStale writes to stderr (never stdout)", async () => { + await withTempDir(async (tempDir) => { + // Write a tampered AGENTS.cli.md so the freshness check trips. + await writeFile( + join(tempDir, "AGENTS.cli.md"), + "# Windmill CLI Agent Instructions\n\nbody\n", + "utf8" + ); + + const stdoutWrites: string[] = []; + const stderrWrites: string[] = []; + const originalStdout = process.stdout.write.bind(process.stdout); + const originalStderr = process.stderr.write.bind(process.stderr); + // @ts-expect-error — overriding write for the test + process.stdout.write = (chunk: any) => { + stdoutWrites.push(String(chunk)); + return true; + }; + // @ts-expect-error — overriding write for the test + process.stderr.write = (chunk: any) => { + stderrWrites.push(String(chunk)); + return true; + }; + + try { + await warnIfPromptsStale({ + cwd: tempDir, + nonDottedPaths: false, + argv: ["node", "wmill", "sync", "push"], + }); + } finally { + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + } + + const stderrJoined = stderrWrites.join(""); + const stdoutJoined = stdoutWrites.join(""); + expect(stderrJoined).toContain("out of date"); + expect(stdoutJoined).not.toContain("out of date"); }); }); }); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 9917afd696..342f4cade4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -387,6 +387,15 @@ List all queues with their metrics - `--instance [instance]` - Name of the instance to push to, override the active instance - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 35637f3a6c..3a0ee24ac8 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2918,6 +2918,15 @@ List all queues with their metrics - \`--instance [instance]\` - Name of the instance to push to, override the active instance - \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 0e4ac513c4..0ea6cf848f 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -392,6 +392,15 @@ List all queues with their metrics - `--instance [instance]` - Name of the instance to push to, override the active instance - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance +### refresh + +Refresh wmill-managed project files (AGENTS.cli.md and skills) + +**Subcommands:** + +- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. + - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + ### resource resource related commands diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 12792da41d..e5a6d609ae 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1828,7 +1828,7 @@ CONTEXT7_REPO_NAME = "windmill-cli-docs" def extract_agents_md_template() -> str: - """Extract the AGENTS.md template string from cli/src/guidance/core.ts. + """Extract the AGENTS.cli.md template string from cli/src/guidance/core.ts. Keeping a single source of truth in TypeScript avoids drift between what `wmill init` writes locally and what we publish for context7 ingestion. @@ -1836,14 +1836,16 @@ def extract_agents_md_template() -> str: core_ts_path = SCRIPT_DIR.parent / "cli" / "src" / "guidance" / "core.ts" content = core_ts_path.read_text() # Anchor on the function name so adding other template-literal-returning - # functions to core.ts can't silently re-target the regex. + # functions to core.ts can't silently re-target the regex. The function + # was renamed from `generateAgentsMdContent` → `generateAgentsCliMdContent` + # when the managed file split out of AGENTS.md into AGENTS.cli.md. match = re.search( - r"function\s+generateAgentsMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", + r"function\s+generateAgentsCliMdContent\b[\s\S]*?return\s+`([\s\S]*?)`;", content, ) if not match: raise RuntimeError( - f"Could not extract AGENTS.md template from {core_ts_path}" + f"Could not extract AGENTS.cli.md template from {core_ts_path}" ) return _unescape_ts_template_literal(match.group(1)) @@ -1865,10 +1867,16 @@ def _unescape_ts_template_literal(raw: str) -> str: def render_agents_md_for_docs( skills: list[str], skill_desc_map: dict[str, str] ) -> str: - """Render AGENTS.md exactly as `wmill init` would, for the docs repo.""" + """Render AGENTS.cli.md exactly as `wmill init` would, for the docs repo. + + The skill reference paths point at `.agents/skills/` (the canonical tree + that Codex/Pi read directly and that Claude Code mirrors under + `.claude/skills/`) — matching `buildSkillsReference` in + `cli/src/guidance/writer.ts`. + """ template = extract_agents_md_template() skills_reference = "\n".join( - f"- `.claude/skills/{name}/SKILL.md` - {skill_desc_map[name]}" + f"- `.agents/skills/{name}/SKILL.md` - {skill_desc_map[name]}" for name in skills if name in skill_desc_map ) @@ -2002,7 +2010,10 @@ def generate_context7_repo( skill_desc_map = build_skill_desc_map(skills) - # AGENTS.md — the same content `wmill init` writes locally. + # AGENTS.md — the managed CLI guidance (what `wmill init` writes as + # AGENTS.cli.md locally). Kept under the `AGENTS.md` filename here to + # preserve the existing context7 ingest path; docs consumers read this + # as the canonical AGENTS file. (target_dir / "AGENTS.md").write_text( render_agents_md_for_docs(skills, skill_desc_map) ) From ac26aa4e4c7cc2d493f136b59738c0708803cc6d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 21 May 2026 15:25:25 +0200 Subject: [PATCH 03/11] feat: add yolo mode for ai chat tools (#9258) * feat: add yolo mode for ai chat tools * nit * fix: align chat footer controls * feat: add ai chat autonomy modes * feat: add autonomy mode dropdown * fix: highlight yolo autonomy icon * fix: auto accept flow edits * fix: hide unsupported autonomy modes * fix: handle auto-accept flow editor races --- .../copilot/chat/AIChatDisplay.svelte | 265 ++++++++++++++---- .../copilot/chat/AIChatInlineWidget.svelte | 2 +- .../copilot/chat/AIChatManager.svelte.ts | 144 +++++++++- .../copilot/chat/AIChatManager.test.ts | 155 ++++++++++ .../copilot/chat/flow/FlowAIChat.svelte | 21 +- .../copilot/chat/script/CodeDisplay.svelte | 2 +- .../components/copilot/chat/shared.test.ts | 43 +++ .../src/lib/components/copilot/chat/shared.ts | 8 +- 8 files changed, 573 insertions(+), 67 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/AIChatManager.test.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 5967a75d30..e689c3e6c0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -4,7 +4,10 @@ import AvailableContextList from './AvailableContextList.svelte' import { type Snippet } from 'svelte' import { + AlertTriangle, ArrowDown, + ChevronDown, + ChevronsRight, CheckIcon, HistoryIcon, Hourglass, @@ -23,16 +26,43 @@ import ProviderModelSelector from './ProviderModelSelector.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' - import { AIMode } from './AIChatManager.svelte' + import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' import type { SelectedContext } from './app/core' + const MAX_YOLO_TOOLTIP_TOOLS = 8 const aiChatManager = getAiChatManager() + type AutonomyModeOption = { label: string; mode: AIAutonomyMode } + const autonomyModeOptions: AutonomyModeOption[] = [ + { label: 'auto accept off', mode: AIAutonomyMode.DEFAULT }, + { label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT }, + { label: 'yolo on', mode: AIAutonomyMode.YOLO } + ] + const autonomyModeLabel = ( + mode: AIAutonomyMode, + options: AutonomyModeOption[] = autonomyModeOptions + ) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label + const isAutonomyModeAvailable = ( + mode: AIAutonomyMode, + autoAcceptEditsAvailable: boolean, + autoAcceptToolConfirmationsAvailable: boolean + ) => { + switch (mode) { + case AIAutonomyMode.DEFAULT: + return true + case AIAutonomyMode.ACCEPT_EDIT: + return autoAcceptEditsAvailable + case AIAutonomyMode.YOLO: + return autoAcceptToolConfirmationsAvailable + } + return false + } let { messages, @@ -179,6 +209,37 @@ aiChatManager.mode === AIMode.GLOBAL || aiChatManager.mode === AIMode.APP ) + const availableAutonomyModeOptions = $derived.by(() => + autonomyModeOptions.filter((option) => + isAutonomyModeAvailable( + option.mode, + aiChatManager.autoAcceptEditsAvailable, + aiChatManager.autoAcceptToolConfirmationsAvailable + ) + ) + ) + const effectiveAutonomyMode = $derived( + availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode) + ? aiChatManager.autonomyMode + : AIAutonomyMode.DEFAULT + ) + const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) + const autonomyModeTooltip = $derived.by(() => { + switch (effectiveAutonomyMode) { + case AIAutonomyMode.ACCEPT_EDIT: + return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.' + case AIAutonomyMode.YOLO: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Automatically accepts tool confirmations.' + } + return 'Automatically accepts script and flow edits plus tool confirmations.' + default: + if (!aiChatManager.autoAcceptEditsAvailable) { + return 'Requires confirmation for tool calls.' + } + return 'Requires confirmation for edits and tool calls.' + } + }) // "Waiting for user" detection — when the latest tool message is staged // for confirmation or has an unanswered askUserQuestion, the AI loop is @@ -209,6 +270,29 @@ } return aiChatManager.appAiChatHelpers.getSelectedContext() }) + + const yoloBypassedTools = $derived.by(() => { + return aiChatManager.tools + .filter((tool) => tool.requiresConfirmation === true) + .map((tool) => ({ + name: tool.def.function.name, + label: tool.confirmationMessage ?? tool.def.function.name + })) + }) + const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS)) + const hiddenYoloBypassedToolCount = $derived( + Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length) + ) + const showFlowPendingActionControls = $derived( + (aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) && + !aiChatManager.autoAcceptEditsActive + ) + const showFooterLeftControls = $derived( + !disabled && + (showContextPicker || + showAutonomyModeSelector || + (aiChatManager.mode === AIMode.SCRIPT && hasDiff)) + )
@@ -322,7 +406,7 @@
{#if waitingForUserAction} @@ -345,7 +429,7 @@ transition:fade={{ duration: 120 }} class={twMerge( 'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md', - aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2' + showFlowPendingActionControls ? 'bottom-12' : 'bottom-2' )} > + {/each} +
+ {/snippet} + +
+ {/if} + {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable} + + + {#snippet text()} +
+

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

+

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

+ {#if yoloBypassedTools.length > 0} +

Bypassed in current mode:

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

+ {hiddenYoloBypassedToolCount} more

+ {/if} + {:else} +

No tools in the current mode require confirmation.

+ {/if} +
+ {/snippet} +
+ {/if} + {#if aiChatManager.mode === AIMode.SCRIPT && hasDiff} + + {/if} + + {/if} {#if disabled}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte index 8e57dde1fe..da1a1e26af 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte @@ -213,7 +213,7 @@ try { const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection) if (reply) { - aiChatManager.scriptEditorApplyCode?.(reply) + await aiChatManager.applyScriptEditorCode(reply) } } catch (error) { console.error('Inline AI request failed:', error) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b56edcc43..93145e3b1b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' import { get } from 'svelte/store' +import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' @@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05 const MAX_TOKENS_HARD_LIMIT = 5000 +const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' +const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' export enum AIMode { SCRIPT = 'script', @@ -77,12 +80,38 @@ export enum AIMode { ASK = 'ask' } +export enum AIAutonomyMode { + DEFAULT = 'default', + ACCEPT_EDIT = 'acceptedit', + YOLO = 'yolo' +} + const ALL_AI_MODES = Object.values(AIMode) +const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode) +const AUTO_ACCEPT_EDIT_MODES = new Set([AIMode.SCRIPT, AIMode.FLOW]) +const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.APP, + AIMode.GLOBAL +]) export function isAIMode(mode: unknown): mode is AIMode { return ALL_AI_MODES.includes(mode as AIMode) } +export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode { + return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode) +} + +export function supportsAutoAcceptEdits(mode: AIMode): boolean { + return AUTO_ACCEPT_EDIT_MODES.has(mode) +} + +export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean { + return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode) +} + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string { return path?.startsWith('f/') === true || path?.startsWith('u/') === true } +function getPersistedAutonomyMode(): AIAutonomyMode { + if (!BROWSER || typeof localStorage === 'undefined') { + return AIAutonomyMode.DEFAULT + } + const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY) + if (isAIAutonomyMode(persistedMode)) { + return persistedMode + } + return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true' + ? AIAutonomyMode.YOLO + : AIAutonomyMode.DEFAULT +} + +function persistAutonomyMode(mode: AIAutonomyMode) { + if (!BROWSER || typeof localStorage === 'undefined') { + return + } + localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode) +} + export class AIChatManager { contextManager = new ContextManager() historyManager = new HistoryManager() @@ -112,6 +161,17 @@ export class AIChatManager { currentReply = $state('') displayMessages = $state([]) messages = $state([]) + autonomyMode = $state(getPersistedAutonomyMode()) + autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) + autoAcceptEditsActive = $derived( + this.autoAcceptEditsAvailable && + (this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT || + this.autonomyMode === AIAutonomyMode.YOLO) + ) + autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode)) + autoAcceptToolConfirmationsActive = $derived( + this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable + ) #automaticScroll = $state(true) systemMessage = $state({ role: 'system', @@ -122,9 +182,9 @@ export class AIChatManager { scriptEditorOptions = $state(undefined) flowOptions = $state(undefined) - scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>( - undefined - ) + scriptEditorApplyCode = $state< + ((code: string, opts?: ReviewChangesOpts) => void | Promise) | undefined + >(undefined) scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined) scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined) flowAiChatHelpers = $state(undefined) @@ -141,7 +201,7 @@ export class AIChatManager { /** Cached datatables for app context (fetched asynchronously) */ cachedDatatables = $state([]) - private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private confirmationCallbacks = new Map void>() private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined @@ -215,20 +275,65 @@ export class AIChatManager { // Request confirmation from user for a tool call requestConfirmation = (toolId: string): Promise => { + if (this.autoAcceptToolConfirmationsActive) { + return Promise.resolve(true) + } + return new Promise((resolve) => { - // Store the callback for this specific tool - this.confirmationCallback = resolve + this.confirmationCallbacks.set(toolId, resolve) }) } // Handle confirmation response for a specific tool handleToolConfirmation = (toolId: string, confirmed: boolean) => { - if (this.confirmationCallback) { - this.confirmationCallback(confirmed) - this.confirmationCallback = undefined + const confirmationCallback = this.confirmationCallbacks.get(toolId) + if (confirmationCallback) { + confirmationCallback(confirmed) + this.confirmationCallbacks.delete(toolId) } } + private acceptPendingToolConfirmations = () => { + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(true) + } + this.confirmationCallbacks.clear() + } + + private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => { + if (flowHelpers?.hasPendingChanges()) { + flowHelpers.acceptAllModuleActions() + } + } + + setAutonomyMode = (mode: AIAutonomyMode) => { + this.autonomyMode = mode + persistAutonomyMode(mode) + + if (this.autoAcceptToolConfirmationsActive) { + this.acceptPendingToolConfirmations() + } + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } + } + + setAutoAcceptToolConfirmations = (enabled: boolean) => { + this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT) + } + + applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => { + if (this.autoAcceptEditsActive && opts?.mode === 'revert') { + return + } + + const effectiveOpts = + this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply' + ? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts) + : opts + await this.scriptEditorApplyCode?.(code, effectiveOpts) + } + requestUserQuestion = ( toolId: string, _question: { question: string; choices: string[] } @@ -346,7 +451,7 @@ export class AIChatManager { }, getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget, applyCode: (code: string, opts?: ReviewChangesOpts) => { - this.scriptEditorApplyCode?.(code, opts) + return this.applyScriptEditorCode(code, opts) }, getLintErrors: () => { if (this.scriptEditorGetLintErrors) { @@ -874,6 +979,7 @@ export class AIChatManager { } }, requestConfirmation: this.requestConfirmation, + shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive, requestUserQuestion: this.requestUserQuestion } } @@ -886,6 +992,9 @@ export class AIChatManager { ...params }) this.messages = [...this.messages, ...(addedMessages ?? [])] + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } await this.historyManager.saveChat(this.displayMessages, this.messages) } catch (err) { console.error(err) @@ -901,10 +1010,10 @@ export class AIChatManager { } cancel = (reason?: string) => { - if (this.confirmationCallback) { - this.confirmationCallback(false) - this.confirmationCallback = undefined + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(false) } + this.confirmationCallbacks.clear() for (const resolveQuestion of this.userQuestionCallbacks.values()) { resolveQuestion(undefined) } @@ -1060,10 +1169,10 @@ export class AIChatManager { listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => { if (currentEditor && currentEditor.type === 'script') { - this.scriptEditorApplyCode = (code) => { + this.scriptEditorApplyCode = async (code, opts) => { if (currentEditor && currentEditor.type === 'script') { currentEditor.hideDiffMode() - currentEditor.editor.reviewAndApplyCode(code) + await currentEditor.editor.reviewAndApplyCode(code, opts) } } this.scriptEditorShowDiffMode = () => { @@ -1164,6 +1273,11 @@ export class AIChatManager { setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => { this.flowAiChatHelpers = flowHelpers + untrack(() => { + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits(flowHelpers) + } + }) return () => { this.flowAiChatHelpers = undefined diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts new file mode 100644 index 0000000000..df2cba5575 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FlowAIChatHelpers } from './flow/core' +import type { CurrentEditor } from '$lib/components/flows/types' +import type { ReviewChangesOpts } from './monaco-adapter' +import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' + +vi.mock('monaco-editor', () => ({ + Selection: class Selection {} +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: {}, + ScriptService: {}, + FlowService: {}, + JobService: {} +})) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => undefined } +})) + +vi.mock('$lib/toast', () => ({ + sendUserToast: vi.fn() +})) + +vi.mock('$lib/aiStore', () => ({ + getCurrentModel: () => undefined, + tryGetCurrentModel: () => undefined, + getCombinedCustomPrompt: () => '' +})) + +vi.mock('../lib', () => ({ + getModelContextWindow: () => 128000, + workspaceAIClients: { subscribe: () => () => undefined } +})) + +vi.mock('./api/apiTools', () => ({ + loadApiTools: vi.fn() +})) + +vi.mock('./chatLoop', () => ({ + runChatLoop: vi.fn() +})) + +vi.mock('./global/gate', () => ({ + isGlobalAiEnabled: () => true +})) + +function createFlowHelpers({ + hasPendingChanges, + acceptAllModuleActions +}: { + hasPendingChanges: () => boolean + acceptAllModuleActions: () => void +}): FlowAIChatHelpers { + return { + getFlowAndSelectedId: vi.fn(), + getRootModules: vi.fn(), + inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() }, + setSnapshot: vi.fn(), + revertToSnapshot: vi.fn(), + setCode: vi.fn(), + setFlowJson: vi.fn(), + getFlowInputsSchema: vi.fn(), + updateExprsToSet: vi.fn(), + acceptAllModuleActions, + rejectAllModuleActions: vi.fn(), + hasPendingChanges, + selectStep: vi.fn(), + testFlow: vi.fn(), + getLintErrors: vi.fn() + } as unknown as FlowAIChatHelpers +} + +describe('AIChatManager autonomy mode', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('accepts pending flow edits when auto-accept is enabled from script mode', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('waits for flow step editor review before resolving applyScriptEditorCode', async () => { + const manager = new AIChatManager() + let finishReview: (() => void) | undefined + const reviewPromise = new Promise((resolve) => { + finishReview = resolve + }) + const hideDiffMode = vi.fn() + const reviewAndApplyCode = vi.fn(() => reviewPromise) + const opts = { mode: 'apply' } satisfies ReviewChangesOpts + + manager.listenForCurrentEditorChanges({ + type: 'script', + stepId: 'step-a', + editor: { + reviewAndApplyCode, + getLintErrors: vi.fn() + }, + showDiffMode: vi.fn(), + hideDiffMode, + diffMode: false, + lastDeployedCode: undefined + } as unknown as CurrentEditor) + + let applied = false + const applyPromise = manager + .applyScriptEditorCode('export async function main() {}', opts) + .then(() => { + applied = true + }) + + await Promise.resolve() + + expect(hideDiffMode).toHaveBeenCalledTimes(1) + expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts) + expect(applied).toBe(false) + + finishReview?.() + await applyPromise + + expect(applied).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index d8ca5858ea..9a12a04548 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index d26f5cbb54..69b868ee50 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -247,6 +247,49 @@ describe('processToolCall', () => { expect(result.content).toBe('ok') }) + it('auto-accepts required confirmations when yolo mode is active', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const fn = vi.fn().mockResolvedValue('ok') + const requestConfirmation = vi.fn() + const setToolStatus = vi.fn() + + const result = await processToolCall({ + tools: [ + { + def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'), + requiresConfirmation: true, + confirmationMessage: 'Create schedule', + fn + } + ], + toolCall: { + id: 'call_yolo', + type: 'function', + function: { name: 'create_schedule', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { + setToolStatus, + removeToolStatus: vi.fn(), + requestConfirmation, + shouldAutoAcceptToolConfirmations: () => true + } + }) + + expect(requestConfirmation).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalled() + expect(setToolStatus).toHaveBeenCalledWith( + 'call_yolo', + expect.objectContaining({ + content: 'Create schedule', + isLoading: true, + needsConfirmation: false + }) + ) + expect(result.content).toBe('ok') + }) + it('blocks workspace mutation tools for undeployed scripts and flows', async () => { const { processToolCall } = await import('./shared') const { createWorkspaceMutationTools } = await import('./workspaceTools') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 0723cc3eb3..88ed20d664 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -582,10 +582,13 @@ export async function processToolCall({ } // Check if tool requires confirmation - const needsConfirmation = tool?.requiresConfirmation + const requiresConfirmation = tool?.requiresConfirmation === true + const autoAcceptConfirmation = + requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true + const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation toolCallbacks.setToolStatus(toolCall.id, { - ...(tool?.requiresConfirmation + ...(requiresConfirmation ? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' } : {}), parameters: args, @@ -695,6 +698,7 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + shouldAutoAcceptToolConfirmations?: () => boolean requestUserQuestion?: ( toolId: string, question: UserQuestionDisplay From 0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:14:29 +0000 Subject: [PATCH 04/11] fix(debugger): add non-root user support to Dockerfile (#9277) Mirrors the main Windmill Dockerfile pattern: creates a windmill user (UID/GID 1000) and makes cache/work directories world-writable so the image runs cleanly under Kubernetes securityContext.runAsNonRoot or runAsUser: 1000 without permission errors on Bun, pip, or windmill cache writes. Fixes WIN-1969 Co-authored-by: Claude Opus 4.7 (1M context) --- debugger/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/debugger/Dockerfile b/debugger/Dockerfile index fe2b769e7c..51e993746a 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -51,6 +51,14 @@ COPY dap_websocket_server.py . # Expose the default port EXPOSE 5679 +# Create a non-root user 'windmill' with UID and GID 1000 (mirrors main Windmill image) +RUN addgroup --gid 1000 windmill && \ + adduser --disabled-password --gecos "" --uid 1000 --gid 1000 windmill + +# Ensure cache and work directories are writable by any UID +RUN mkdir -p /tmp/windmill/cache /tmp/windmill/cache_nomount /tmp/.cache && \ + chmod -R 777 /tmp/windmill /tmp/.cache /app + # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://localhost:5679/health || exit 1 From 0692b97c8a3818549d7050ea3e057e9cbf1ddb44 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:21:27 +0000 Subject: [PATCH 05/11] fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276) * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path The AI proxy handler accepts an X-Resource-Path header to override the configured workspace AI provider. When supplied, the handler loaded the resource value from the resource table using the root DB pool with no resources:read scope check, so any authenticated workspace user could point X-Resource-Path at a restricted AI resource (e.g. one in a folder they cannot read) and the proxy would use that resource's provider credentials for the outbound AI request. For user-supplied resource paths, now require resources:read:{path} scope and fetch the resource through user_db.begin(&authed) so RLS enforces the same folder/group boundary as the resource API. The RLS- scoped $var: resolution stays in place as defense in depth. The admin-configured workspace/instance ai_config path is unchanged. Fixes WIN-1971 Co-Authored-By: Claude Opus 4.7 (1M context) * test(ai): regression test for X-Resource-Path RLS enforcement Cover all four cases: - non-admin pointing X-Resource-Path at a restricted resource is rejected - non-admin pointing it at a resource they own still works - admin can point it at any resource - workspace-configured proxy flow (no X-Resource-Path) is unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../tests/ai_routes.rs | 126 ++++++++++++++++++ backend/windmill-api/src/ai.rs | 45 ++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/ai_routes.rs b/backend/windmill-api-integration-tests/tests/ai_routes.rs index 72b284fcd1..cfd93caef5 100644 --- a/backend/windmill-api-integration-tests/tests/ai_routes.rs +++ b/backend/windmill-api-integration-tests/tests/ai_routes.rs @@ -10,6 +10,10 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { builder.header("Authorization", "Bearer SECRET_TOKEN") } +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {token}")) +} + fn assert_2xx(status: u16, body: &str, endpoint: &str) { assert!( (200..300).contains(&status), @@ -106,3 +110,125 @@ async fn test_ai_proxy_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Regression test for WIN-1971: the AI proxy's X-Resource-Path header must +/// honour resource RLS so that a low-privilege user cannot point the proxy +/// at a resource they are not allowed to read. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ai_proxy_x_resource_path_enforces_rls(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let mock_port = start_mock_ai_api().await; + let mock_url = format!("http://127.0.0.1:{mock_port}/v1"); + + // Resource owned by test-user (admin). With default extra_perms {} the + // RLS `see_own` policy restricts SELECT to user `test-user`. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user/restricted_openai', $1::jsonb, 'openai', '{}', 'test-user')", + ) + .bind(json!({ + "api_key": "sk-secret-restricted", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + // Sanity-check: normal resource API rejects test-user-3 (non-admin) for the restricted path. + let resp = authed_with( + client().get(format!( + "http://localhost:{port}/api/w/test-workspace/resources/get/u/test-user/restricted_openai" + )), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert!( + resp.status().as_u16() >= 400, + "normal resource API should deny test-user-3 reading restricted resource, got {}", + resp.status() + ); + + // The vulnerability: as a non-admin user, point X-Resource-Path at the + // restricted resource. Must be rejected before the proxy fetches/uses it. + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status >= 400, + "non-admin user should be rejected when X-Resource-Path points at a resource they cannot read, got {status}: {body}", + ); + + // A resource the non-admin owns must still work through X-Resource-Path. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user-3/own_openai', $1::jsonb, 'openai', '{}', 'test-user-3')", + ) + .bind(json!({ + "api_key": "sk-self", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user-3/own_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "non-admin with X-Resource-Path on owned resource", + ); + + // Admin must still be able to use X-Resource-Path on any resource. + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "admin with X-Resource-Path on restricted resource", + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 05bf995700..e7bc089d7c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -1,6 +1,7 @@ #[cfg(feature = "bedrock")] use crate::bedrock; use crate::db::{ApiAuthed, DB}; +use crate::utils::check_scopes; #[cfg(feature = "bedrock")] use axum::routing::get; @@ -669,6 +670,7 @@ async fn global_proxy( async fn proxy( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, mut ai_path)): Path<(String, String)>, method: Method, headers: HeaderMap, @@ -689,6 +691,16 @@ async fn proxy( .get("X-Resource-Path") .map(|v| v.to_str().unwrap_or("").to_string()); let is_user_specified_resource = forced_resource_path.is_some(); + + // When the caller supplies X-Resource-Path, the resource is treated as if it + // were being read through the normal resource API: scope and RLS checks must + // apply so that a low-privilege user cannot point the proxy at a restricted + // AI resource (e.g. one in a folder they cannot read) to exfiltrate the + // resource's provider credentials or use them via the proxy. + if let Some(resource_path) = forced_resource_path.as_ref() { + check_scopes(&authed, || format!("resources:read:{}", resource_path))?; + } + let request_config = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.config @@ -759,13 +771,32 @@ async fn proxy( ) }; - let resource = sqlx::query_scalar::<_, Option>>>( - "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", - ) - .bind(&resource_path) - .bind(&resource_workspace) - .fetch_optional(&db) - .await? + // For user-specified resources, fetch through an RLS-scoped + // connection so PostgreSQL row-level security enforces the same + // folder/group boundaries as the regular resource API. For the + // workspace/instance ai_config path, the resource_path was already + // validated by an admin/devops user when configuring the workspace, + // so the raw pool is used. + let resource = if is_user_specified_resource { + let mut tx = user_db.clone().begin(&authed).await?; + let res = sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + res + } else { + sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&db) + .await? + } .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?; From d0ee697e8b8de58085ea0b2ecde1af2b2441428d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 21 May 2026 17:30:17 +0200 Subject: [PATCH 06/11] feat: add userdraft listing primitives (#9268) * feat: add userdraft listing primitives * fix: cancel stale userdraft discard writes * docs: remove global ai userdraft plan --- frontend/src/lib/svelte5Utils.svelte.ts | 26 ++- frontend/src/lib/userDraft.svelte.ts | 190 ++++++++++++++++--- frontend/src/lib/userDraft.test.ts | 240 ++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index e3a927281a..aad16431cb 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -601,7 +601,7 @@ export function useLocalStorageValue( */ transformBeforePersist?: (val: T) => T } -): { val: T; skipNextWriteOnce(): void } { +): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 const transformBeforePersist = options?.transformBeforePersist @@ -626,7 +626,9 @@ export function useLocalStorageValue( } } - if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} } + if (typeof window === 'undefined') { + return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} } + } const savedValue = localStorage.getItem(key) let s = $state( savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue @@ -662,6 +664,13 @@ export function useLocalStorageValue( pendingValue = undefined }, debounceMs) } + const cancelPendingWrite = () => { + if (debounceTimer != null) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + pendingValue = undefined + } $effect(() => { readFieldsRecursively(s) @@ -698,12 +707,19 @@ export function useLocalStorageValue( /** * Arm the persist skip so the next `set val` (or deep-mutation flush) * updates only the in-memory cell and leaves localStorage untouched. - * Used by `UserDraft.discard` to reset the in-memory state to a - * fallback without re-persisting it — pairs with an explicit LS - * delete to leave the slot empty. */ skipNextWriteOnce(): void { skipNextWrite = true + }, + /** + * Reset the in-memory state while canceling any queued debounced write. + * Used when a caller performs its own synchronous persistence action. + */ + setWithoutPersist(newVal: T): void { + cancelPendingWrite() + lastSerialized = newVal === undefined ? undefined : serialize(newVal) + skipNextWrite = false + s = newVal } } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 923b87fec5..3d83952513 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' -export type UserDraftItemKind = - | 'script' - | 'flow' - | 'app' - | 'raw_app' - | 'resource' - | 'variable' - | 'trigger_schedule' - | 'trigger_webhook' - | 'trigger_default_email' - | 'trigger_email' - | 'trigger_http' - | 'trigger_websocket' - | 'trigger_postgres' - | 'trigger_kafka' - | 'trigger_nats' - | 'trigger_mqtt' - | 'trigger_sqs' - | 'trigger_gcp' - | 'trigger_azure' - | 'trigger_poll' - | 'trigger_cli' - | 'trigger_nextcloud' - | 'trigger_google' - | 'trigger_github' +export const USER_DRAFT_ITEM_KINDS = [ + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +] as const + +export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number] export type UserDraftOptions = { workspace?: string @@ -43,6 +46,10 @@ export type UserDraftUseOptions = UserDraftOptions & { defaultValue?: V } +export type UserDraftListOptions = UserDraftOptions & { + itemKinds?: readonly UserDraftItemKind[] +} + /** * A single (kind, path, workspace) tuple that `useMany` should hold a handle * for. The shape mirrors `use()`'s arguments, just bundled into one object @@ -93,10 +100,14 @@ function stamp(stored: StoredDraft | undefined): StoredDraft | undefine type DraftState = { val: StoredDraft | undefined skipNextWriteOnce(): void + setWithoutPersist(newVal: StoredDraft | undefined): void } type DraftEntry = { count: number + workspace: string + itemKind: UserDraftItemKind + path: string state: DraftState /** * Tears down the `$effect.root` scope that owns the entry's @@ -109,6 +120,16 @@ type DraftEntry = { destroyRoot?: () => void } +export type UserDraftEntry = { + workspace: string + itemKind: UserDraftItemKind + path: string + value: V | undefined + meta: UserDraftMeta + persisted: boolean + live: boolean +} + const entries = new Map() function resolveWorkspace(opts?: UserDraftOptions): string { @@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s return `userdraft/w/${workspace}/${itemKind}/${path}` } +function parseLocalStorageKey( + key: string, + workspace: string, + itemKinds: readonly UserDraftItemKind[] +): { itemKind: UserDraftItemKind; path: string } | undefined { + const prefix = `userdraft/w/${workspace}/` + if (!key.startsWith(prefix)) return undefined + const rest = key.slice(prefix.length) + for (const itemKind of itemKinds) { + const kindPrefix = `${itemKind}/` + if (rest.startsWith(kindPrefix)) { + return { itemKind, path: rest.slice(kindPrefix.length) } + } + } + return undefined +} + +function snapshotDraftValue(value: V | undefined): V | undefined { + if (value === undefined) return undefined + try { + return structuredClone($state.snapshot(value)) as V + } catch { + try { + return JSON.parse(JSON.stringify(value)) as V + } catch { + return undefined + } + } +} + export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) @@ -299,6 +350,27 @@ export const UserDraft = { } }, + setDraftAndMeta( + itemKind: UserDraftItemKind, + path: string, + value: V | undefined, + meta: UserDraftMeta, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + entry.state.val = wrap(value, meta) + // Static writes represent explicit external draft mutations. A + // freshly acquired live entry may still have the initial-write skip + // armed, so force the storage slot to match the live value. + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + return + } + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + }, + /** * Autosave gate: persist `value` only when it differs (after * `normalizeForCompare`) from the `deployed` baseline; otherwise remove @@ -396,6 +468,61 @@ export const UserDraft = { } }, + clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + UserDraft.discard(itemKind, path, undefined, opts) + }, + + list(opts?: UserDraftListOptions): UserDraftEntry[] { + const ws = resolveWorkspace(opts) + const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS + const out = new Map>() + + if (typeof localStorage !== 'undefined') { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key) + } + for (const key of keys) { + const parsed = parseLocalStorageKey(key, ws, itemKinds) + if (!parsed) continue + const stored = readPersisted(key) + if (stored === undefined) continue + out.set(mapKey(ws, parsed.itemKind, parsed.path), { + workspace: ws, + itemKind: parsed.itemKind, + path: parsed.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: true, + live: false + }) + } + } + + for (const entry of entries.values()) { + if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue + const stored = untrack(() => entry.state.val as StoredDraft | undefined) + const mk = mapKey(entry.workspace, entry.itemKind, entry.path) + if (stored === undefined) { + out.delete(mk) + continue + } + const existing = out.get(mk) + out.set(mk, { + workspace: entry.workspace, + itemKind: entry.itemKind, + path: entry.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: existing?.persisted ?? false, + live: true + }) + } + + return Array.from(out.values()) + }, + /** * Like `remove`, but also resets any live handle's `draft` to * `fallback` in-memory (so reactive readers see it immediately) and @@ -412,10 +539,11 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - // Arm the skip before the cell write so the setter suppresses - // the persist; the removeItem below actually clears the slot. - entry.state.skipNextWriteOnce() - entry.state.val = wrap(fallback) as StoredDraft | undefined + // Drop any queued debounced write owned by this live entry before + // resetting the in-memory value. Otherwise a timer from the old + // entry can outlive unmount and later delete a freshly written + // draft for the same key. + entry.state.setWithoutPersist(wrap(fallback) as StoredDraft | undefined) } try { localStorage.removeItem(localStorageKey(ws, itemKind, path)) @@ -549,7 +677,7 @@ function acquireEntry( ) }) if (stateRef) { - entries.set(mk, { count: 1, state: stateRef, destroyRoot }) + entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot }) return } // Fallback for the vitest runtime where `$effect.root`'s callback isn't @@ -560,7 +688,7 @@ function acquireEntry( undefined, useLocalStorageOptions ) - entries.set(mk, { count: 1, state }) + entries.set(mk, { count: 1, workspace, itemKind, path, state }) } function releaseEntry(mk: string): void { diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index 90d25000e9..f703953f11 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => { expect(storedShape(KEY)).toBe(wrapped(value)) }) }) + +describe('UserDraft.list / clear / setDraftAndMeta', () => { + it('enumerates persisted-only drafts for the requested workspace and kinds', () => { + UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' }) + UserDraft.setDraftAndMeta( + 'flow', + 'f/b', + { path: 'f/b', value: { modules: [] } }, + { remoteRev: 2 }, + { workspace: 'other_ws' } + ) + UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {}) + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/a', + value: { path: 'f/a', content: 'a' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: false + } + ]) + expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([ + expect.objectContaining({ + workspace: 'other_ws', + itemKind: 'flow', + path: 'f/b', + persisted: true, + live: false + }) + ]) + }) + + it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => { + UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {}) + + const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] }) + + expect(entries).toHaveLength(3) + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + itemKind: 'script', + path: '', + value: { path: '', content: 'scratch' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-a', + value: { path: 'f/new-a', content: 'a' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-b', + value: { path: 'f/new-b', content: 'b' } + }) + ]) + ) + }) + + it('enumerates live-only drafts before the debounce persists them', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live') + handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' }) + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/live', + value: { path: 'f/live', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: false, + live: true + } + ]) + }) + + it('dedupes entries that are both persisted and live', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/both', + { path: 'f/both', content: 'persisted' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both') + handle.draft = { path: 'f/both', content: 'live' } + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/both', + value: { path: 'f/both', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: true + } + ]) + }) + + it('clear removes persisted storage and live state without re-persisting', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/clear', + { path: 'f/clear', content: 'x' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear') + expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' }) + + UserDraft.clear('script', 'f/clear') + flushPersist() + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('clear cancels pending debounced live writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear') + handle.draft = { path: 'f/pending-clear', content: 'initial' } + handle.draft = { path: 'f/pending-clear', content: 'pending' } + + UserDraft.clear('script', 'f/pending-clear') + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + }) + + it('clear does not let an old debounced remove delete a later direct write', () => { + const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear' + const handle = UserDraft.use<{ path: string; content: string }>( + 'script', + 'f/rewrite-after-clear' + ) + handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' } + handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' } + + UserDraft.clear('script', 'f/rewrite-after-clear') + flushDestroyCallbacks() + UserDraft.setDraftAndMeta( + 'script', + 'f/rewrite-after-clear', + { path: 'f/rewrite-after-clear', content: 'new' }, + {} + ) + + flushPersist() + expect(storedShape(key)).toBe( + wrapped({ path: 'f/rewrite-after-clear', content: 'new' }) + ) + }) + + it('list hides persisted drafts when a live handle has cleared the value', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/live-clear', + { path: 'f/live-clear', content: 'persisted' }, + {} + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear') + handle.draft = { path: 'f/live-clear', content: 'edited' } + handle.draft = undefined + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta') + + UserDraft.setDraftAndMeta( + 'script', + 'f/meta', + { path: 'f/meta', content: 'first' }, + { + remoteRev: 'h1', + remoteDraftRev: 'd1' + } + ) + handle.draft = { path: 'f/meta', content: 'second' } + + expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' }) + expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' }) + expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual( + expect.objectContaining({ + value: { path: 'f/meta', content: 'second' }, + meta: { remoteRev: 'h1', remoteDraftRev: 'd1' } + }) + ) + }) + + it('static setDraftAndMeta persists first writes even when a live handle exists', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live') + + UserDraft.setDraftAndMeta( + 'script', + 'f/static-live', + { path: 'f/static-live', content: 'first' }, + { remoteRev: 'h1' } + ) + + expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' }) + expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe( + JSON.stringify({ + value: { path: 'f/static-live', content: 'first' }, + remoteRev: 'h1' + }) + ) + }) + + it('lists live drafts with runtime-only values without throwing', () => { + const handle = UserDraft.use>('script', 'f/runtime') + handle.draft = { + path: 'f/runtime', + content: 'x', + callback: () => 'not serializable' + } + + expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + expect.objectContaining({ + itemKind: 'script', + path: 'f/runtime', + value: { path: 'f/runtime', content: 'x' } + }) + ]) + }) +}) From b656dc6cdc8c50ef9740240447f119cceed18547 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:34:49 +0000 Subject: [PATCH 07/11] feat(nsjail): optional disk-backed /tmp via instance setting (#9272) * feat(nsjail): optional disk-backed /tmp via instance setting * test(nsjail): unit-test tmp mount resolver and narrow visibility * refactor(nsjail): switch tmp backing to select + conditional UI * ui(nsjail): make tmpfs the visible default in /tmp backing select * fix(nsjail): refuse preexisting jail_tmp to block symlink escape * fix(nsjail): allow jail_tmp reuse on sequential nsjail calls Codex flagged that python/ruby/rust executors invoke nsjail twice per job_dir (install then run). The previous resolver treated any preexisting jail_tmp as hostile and silently fell back to tmpfs on the second call, so disk-backed mode never reached the main script run for those langs. Use symlink_metadata().is_dir() to distinguish a real directory left by an earlier call in the same job_dir (safe to reuse) from a symlink or other entity (still refused, as the codebase-tar escape requires). Also loosen the frontend visibility predicate: only hide nsjail settings when job_isolation is explicitly 'none' or 'unshare', so deployments that enable nsjail via DISABLE_NSJAIL=false with no DB setting can still see the controls. --- backend/src/main.rs | 26 +- backend/src/monitor.rs | 31 +- .../windmill-common/src/global_settings.rs | 3 + .../windmill-common/src/instance_config.rs | 2 + .../nsjail/download.py.config.proto | 7 +- .../nsjail/download.ruby.config.proto | 7 +- .../nsjail/download.rust.config.proto | 7 +- .../nsjail/run.ansible.config.proto | 7 +- .../nsjail/run.bash.config.proto | 7 +- .../nsjail/run.bun.config.proto | 7 +- .../nsjail/run.csharp.config.proto | 7 +- .../nsjail/run.go.config.proto | 7 +- .../nsjail/run.java.config.proto | 7 +- .../nsjail/run.nu.config.proto | 7 +- .../nsjail/run.php.config.proto | 7 +- .../nsjail/run.powershell.config.proto | 7 +- .../nsjail/run.python3.config.proto | 7 +- .../windmill-worker/nsjail/run.r.config.proto | 7 +- .../nsjail/run.ruby.config.proto | 7 +- .../nsjail/run.rust.config.proto | 7 +- .../windmill-worker/src/ansible_executor.rs | 6 +- backend/windmill-worker/src/bash_executor.rs | 6 +- backend/windmill-worker/src/bun_executor.rs | 6 +- backend/windmill-worker/src/common.rs | 276 +++++++++++++++++- .../windmill-worker/src/csharp_executor.rs | 6 +- backend/windmill-worker/src/go_executor.rs | 6 +- backend/windmill-worker/src/java_executor.rs | 6 +- backend/windmill-worker/src/nu_executor.rs | 6 +- backend/windmill-worker/src/php_executor.rs | 6 +- backend/windmill-worker/src/pwsh_executor.rs | 6 +- .../windmill-worker/src/python_executor.rs | 10 +- backend/windmill-worker/src/r_executor.rs | 6 +- backend/windmill-worker/src/ruby_executor.rs | 11 +- backend/windmill-worker/src/rust_executor.rs | 10 +- backend/windmill-worker/src/worker.rs | 6 + .../src/lib/components/InstanceSetting.svelte | 14 + .../src/lib/components/instanceSettings.ts | 16 +- 37 files changed, 413 insertions(+), 164 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index a322b716b1..ecc49c22f6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -51,14 +51,15 @@ use windmill_common::{ JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, - OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, - POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, - REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, - UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, + NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, + NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, + PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, + PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, + REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, + TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -127,10 +128,10 @@ use crate::monitor::{ reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_instance_events_webhook_setting, reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, - reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmpfs_size_setting, - reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, - reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, - reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, + reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, + reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, + reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1785,6 +1786,7 @@ async fn process_notify_event( JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await, JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, + NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 70575aeb21..6f33e6255f 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -62,13 +62,13 @@ use windmill_common::{ HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, - NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, + NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, + OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, + POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, + REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, + UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, }, indexer::load_indexer_config, jwt::JWT_SECRET, @@ -108,9 +108,9 @@ use windmill_worker::{ BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, - NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, - POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -387,6 +387,7 @@ pub async fn initial_load( reload_job_default_timeout_setting(&conn).await; reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; + reload_nsjail_tmp_backing_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -1909,6 +1910,16 @@ pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) { .await; } +pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + NSJAIL_TMP_BACKING_SETTING, + "NSJAIL_TMP_BACKING", + NSJAIL_TMP_BACKING.clone(), + ) + .await; +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 063f7d9a43..5b14d809bd 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -55,6 +55,9 @@ pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; pub const JOB_ISOLATION_SETTING: &str = "job_isolation"; pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; +pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; +pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; +pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index eca3acd456..1872b52140 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -223,6 +223,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub nsjail_tmpfs_size_mb: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub nsjail_tmp_backing: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub bun_install_min_release_age: Option, #[serde(skip_serializing_if = "Option::is_none")] pub uv_exclude_newer: Option, diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index 8880a565c7..e56ef66de0 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 98422abb87..4d6b398fda 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{TARGET}" diff --git a/backend/windmill-worker/nsjail/download.rust.config.proto b/backend/windmill-worker/nsjail/download.rust.config.proto index 0884034076..57e3c2d7e8 100644 --- a/backend/windmill-worker/nsjail/download.rust.config.proto +++ b/backend/windmill-worker/nsjail/download.rust.config.proto @@ -49,12 +49,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "/etc" diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 7df486c53a..11660a5372 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -66,12 +66,7 @@ mount { is_bind: false } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.yml" diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 1136c8d298..899f7caa1e 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -68,12 +68,7 @@ mount { -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.sh" diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 280ef8a7f9..43e4464beb 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -60,12 +60,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/package.json" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index b58d6c330f..c624e55c43 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -57,12 +57,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index 4fec8f3b2d..1e6de06c9c 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 3b0a635b0c..9ae43f5b52 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 40a5de69a1..6c517a5b39 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{NU_PATH}" diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 965ac7cd36..6910cbe1a0 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.php" diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index bab869c9c8..5fd758a813 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -64,12 +64,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.ps1" diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index dec9d6bae7..53d5a6c64d 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -54,12 +54,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/{MAIN}.py" diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index 0f5d71bf5a..bc4d74bcba 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 3b9509cea6..2527e785b6 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 06da7be662..070a76e997 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index f745bc1661..7b0ae3b3ea 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -30,7 +30,7 @@ use crate::{ bash_executor::BIN_BASH, common::{ build_command_with_isolation, check_executor_binary_exists, get_reserved_variables, - read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, @@ -1457,8 +1457,8 @@ mount {{ additional_python_paths_folders.as_str(), ) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 47f100bd2d..516c75fdba 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -41,7 +41,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -216,8 +216,8 @@ exit $exit_status .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 2346cb413f..1e4f4aafd6 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -16,7 +16,7 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmpfs_size_bytes, start_child_process, write_file_binary, MaybeLock, + resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -2186,8 +2186,8 @@ try {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 95c7063328..aa3c6dbe2a 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -15,6 +15,7 @@ use tokio::process::Command; use tokio::{fs::File, io::AsyncReadExt}; use windmill_common::flows::Step; +use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK; use windmill_common::variables::{build_crypt_with_key_suffix, decrypt}; use windmill_common::worker::{ to_raw_value, update_ping_for_failed_init_script_query, write_file, Connection, Ping, PingType, @@ -48,7 +49,8 @@ use tokio::{io::AsyncWriteExt, time::Instant}; use crate::agent_workers::UPDATE_PING_URL; use crate::{ - JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, PATH_ENV, + JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, + NSJAIL_TMP_BACKING, PATH_ENV, }; use windmill_common::client::AuthedClient; @@ -1023,6 +1025,278 @@ pub async fn resolve_nsjail_tmpfs_size_bytes() -> String { } } +/// Sub-directory inside each job dir used as the disk-backed `/tmp` when +/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing +/// job-dir cleanup removes it for free. +const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp"; + +fn tmpfs_mount_block(size_bytes: &str) -> String { + format!( + "mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}" + ) +} + +fn bind_mount_block(jail_tmp: &str) -> String { + format!( + "mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}" + ) +} + +/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the +/// sandbox. +/// +/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job +/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this +/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with +/// `rw: true`. Callers must not pass user-controlled paths. +/// +/// Some executors (e.g. the bun codebase path) extract user-supplied archives +/// into `job_dir` before this resolver runs, so the resolver actively refuses +/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to +/// avoid bind-mounting an attacker-controlled host directory as `/tmp`. +/// +/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a +/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the +/// directory. If creation or the pre-existence check fails, logs an error and +/// falls back to the historical tmpfs block so the job can still start. For +/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the +/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`. +pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String { + let disk_backed = NSJAIL_TMP_BACKING + .read() + .await + .as_deref() + .map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK)) + .unwrap_or(false); + let size_bytes = resolve_nsjail_tmpfs_size_bytes().await; + if !disk_backed { + return tmpfs_mount_block(&size_bytes); + } + let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + // SECURITY: never bind-mount a symlinked (or otherwise non-directory) + // entry at jail_tmp. `symlink_metadata` returns the link's own metadata + // without following it, so `is_dir()` is true only for a real directory. + // User-controlled archives extracted into job_dir could otherwise plant + // `jail_tmp` as a symlink to an arbitrary host directory, which nsjail + // would then expose as a writable /tmp. + // + // A pre-existing real directory at this path is legitimate: several + // executors (python_executor, ruby_executor, rust_executor) invoke nsjail + // more than once per job_dir (e.g. dep install, then run), and the first + // invocation will have created it via the `create_dir` below. + match tokio::fs::symlink_metadata(&jail_tmp).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Err(e) = tokio::fs::create_dir(&jail_tmp).await { + tracing::error!( + "Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + Ok(meta) if meta.is_dir() => { + // Real directory left over from an earlier nsjail invocation in + // this same job_dir — safe to reuse. + } + Ok(_) => { + tracing::error!( + "Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \ + exists but is not a regular directory (possibly a symlink \ + planted by a user-controlled archive). Falling back to \ + RAM-backed tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + Err(e) => { + tracing::error!( + "Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + bind_mount_block(&jail_tmp) +} + +#[cfg(test)] +mod nsjail_tmp_mount_tests { + use super::*; + + #[test] + fn tmpfs_block_renders_size() { + let block = tmpfs_mount_block("800000000"); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=800000000\"")); + assert!(!block.contains("is_bind")); + } + + #[test] + fn bind_block_renders_source_path() { + let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp"); + assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\"")); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("is_bind: true")); + assert!(block.contains("rw: true")); + assert!(!block.contains("fstype")); + } + + /// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING` + /// so they don't race when cargo runs them in parallel. + static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + async fn with_tmp_backing(value: Option, f: F) -> T + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let _serial = SETTING_GUARD.lock().await; + let prev = NSJAIL_TMP_BACKING.read().await.clone(); + *NSJAIL_TMP_BACKING.write().await = value; + let res = f().await; + *NSJAIL_TMP_BACKING.write().await = prev; + res + } + + #[tokio::test] + async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() { + let block = with_tmp_backing(Some("tmpfs".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=")); + assert!(!block.contains("is_bind")); + } + + #[tokio::test] + async fn unset_defaults_to_tmpfs() { + let block = with_tmp_backing(None, || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and + /// emit a bind block pointing at it. + #[tokio::test] + async fn disk_backed_creates_jail_tmp_and_returns_bind_block() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + assert!( + std::path::Path::new(&expected_dir).is_dir(), + "jail_tmp dir should have been created at {expected_dir}" + ); + assert!(block.contains("is_bind: true")); + assert!(block.contains(&format!("src: \"{expected_dir}\""))); + } + + /// Disk-backed branch fallback: if `create_dir_all` fails, we must emit + /// the tmpfs block instead of returning an invalid bind config. + #[tokio::test] + async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() { + // /proc is a kernel filesystem that disallows directory creation, + // so create_dir_all on a subpath returns EPERM/EACCES. + let job_dir = "/proc/win1967_should_not_exist"; + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(job_dir).await + }) + .await; + + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Unknown values fall through to the tmpfs branch instead of crashing. + #[tokio::test] + async fn unknown_value_defaults_to_tmpfs() { + let block = with_tmp_backing(Some("bogus".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Security regression: if a pre-existing symlink sits at the jail_tmp + /// path (e.g. planted by a user-controlled tarball extracted into + /// `job_dir` before the resolver runs), the resolver must refuse the + /// bind mount and fall back to tmpfs — never bind-mount the symlink + /// target into the sandbox as /tmp. + #[tokio::test] + async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + // Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host + // path. Target doesn't have to exist — what matters is that the + // resolver doesn't follow it. + let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink"); + assert!(std::path::Path::new(&jail_tmp_path).is_symlink()); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + // Fell back to tmpfs — no bind-mount of the attacker-controlled path. + assert!( + block.contains("fstype: \"tmpfs\""), + "expected tmpfs fallback, got: {block}" + ); + assert!( + !block.contains("is_bind"), + "must not emit bind block, got: {block}" + ); + assert!( + !block.contains("/etc"), + "must not leak the symlink target into the proto, got: {block}" + ); + } + + /// Sequential resolver calls in the same `job_dir` (e.g. Python uv install + /// → Python run, Ruby install → run, Rust build → run) must keep using + /// the bind mount instead of silently falling back to tmpfs on the + /// second call. The first call creates `jail_tmp`; subsequent calls see + /// it as a pre-existing real directory and must accept it. + #[tokio::test] + async fn disk_backed_reuses_jail_tmp_across_sequential_calls() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + let (first, second) = with_tmp_backing(Some("disk".to_string()), || async { + let first = resolve_nsjail_tmp_mount_block(&job_dir).await; + // Simulate an executor that completes its first nsjail invocation + // (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail + // again for the main run. + assert!(std::path::Path::new(&expected_dir).is_dir()); + let second = resolve_nsjail_tmp_mount_block(&job_dir).await; + (first, second) + }) + .await; + + assert!(first.contains("is_bind: true"), "first call: {first}"); + assert!( + second.contains("is_bind: true"), + "second call regressed to tmpfs: {second}" + ); + assert!(second.contains(&format!("src: \"{expected_dir}\""))); + } +} + async fn hash_args( #[allow(unused)] db: &DB, #[allow(unused)] client: &AuthedClient, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index cfd9a544a1..70df9fb4e0 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -27,7 +27,7 @@ use windmill_queue::CanceledBy; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -604,8 +604,8 @@ pub async fn handle_csharp_job( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index ee08aa1b30..c10e41752c 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, handle_child::handle_child, @@ -352,8 +352,8 @@ func Run(req Req) (interface{{}}, error){{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index f927f921c8..35541c3ee3 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override, @@ -671,8 +671,8 @@ async fn run<'a>( // .replace("{CACHED_TARGET}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index d9a55c40c9..8aa0946666 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,7 +14,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, @@ -259,8 +259,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index e0ba015951..08a92b3641 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -426,8 +426,8 @@ try {{ .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index 5ec6ff6bcd..1ce2eb09b3 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -26,7 +26,7 @@ lazy_static::lazy_static! { use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -683,8 +683,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", .replace("{SHARED_MOUNT}", shared_mount) .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 2930c9d339..4bec8d44bf 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -146,7 +146,7 @@ use windmill_object_store::OBJECT_STORE_SETTINGS; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -1028,8 +1028,8 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; @@ -2056,8 +2056,8 @@ async fn spawn_uv_install( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .as_str(), )?; diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 19959ee22c..9b2bbc6874 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmpfs_size_bytes, start_child_process, OccupancyMetrics, + read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -582,8 +582,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), )?; diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index c82d5ad7df..7871c050c6 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -619,7 +619,7 @@ async fn install<'a>( envs.clone(), get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?, ); - let nsjail_tmpfs_size = resolve_nsjail_tmpfs_size_bytes().await; + let nsjail_tmp_mount_block = resolve_nsjail_tmp_mount_block(&job_dir).await; par_install_language_dependencies_seq( InstallDeps::Flat(deps.clone()), "ruby", @@ -639,7 +639,7 @@ async fn install<'a>( .replace("{TARGET}", &dependency.path) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("{NSJAIL_TMPFS_SIZE}", &nsjail_tmpfs_size) + .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); @@ -812,7 +812,10 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{NSJAIL_TMPFS_SIZE}", &resolve_nsjail_tmpfs_size_bytes().await) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 0f404569da..5941e9db1c 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -481,8 +481,8 @@ pub async fn build_rust_crate( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{BUILD}", &build_dir), )?; @@ -706,8 +706,8 @@ pub async fn handle_rust_job( .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b85c35d65b..6b1216ad9d 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -687,6 +687,12 @@ lazy_static::lazy_static! { /// `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES` (800MB). pub static ref NSJAIL_TMPFS_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + /// Selects how `/tmp` is backed inside nsjail sandboxes. `Some("disk")` + /// switches to a bind mount on `{JOB_DIR}/jail_tmp` (disk-backed); any + /// other value (including `None` or `Some("tmpfs")`) keeps the historical + /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. + pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index dff8591322..834ef0b96c 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -72,6 +72,20 @@ return false } } + // Hide the nsjail-only settings only when isolation is *explicitly* a + // non-nsjail mode. When `job_isolation` is unset, nsjail may still be + // enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so + // keep the controls reachable. + if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') { + const isolation = values['job_isolation'] + if (isolation === 'none' || isolation === 'unshare') { + return false + } + } + // The tmpfs size knob is meaningless when /tmp is disk-backed. + if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') { + return false + } return true } diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index d8cf5901ec..a127027dac 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -245,11 +245,25 @@ export const settings: Record = { } ] }, + { + label: 'Nsjail /tmp backing', + key: 'nsjail_tmp_backing', + fieldType: 'select', + description: + 'How /tmp is backed inside the nsjail sandbox. RAM (tmpfs) is the default — fast, with a hard size cap from Nsjail tmpfs size, but consumes worker memory. Disk (bind mount) uses a per-job directory on the worker disk — no RAM cost, but the only remaining per-file ceiling is rlimit_fsize (~1GB for python/ansible, unbounded for most other languages because they set disable_rl: true); pair with host disk monitoring or quotas.', + storage: 'setting', + placeholder: 'tmpfs', + defaultValue: () => 'tmpfs', + select_items: [ + { label: 'RAM (tmpfs) — default', value: 'tmpfs' }, + { label: 'Disk (bind mount)', value: 'disk' } + ] + }, { label: 'Nsjail tmpfs size (MB)', key: 'nsjail_tmpfs_size_mb', description: - 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when the job isolation mode is set to Nsjail.', + 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when Nsjail /tmp backing is RAM (tmpfs).', fieldType: 'number', placeholder: '800', storage: 'setting' From e6f80dad1c247d0e8289defd91e259ee31e6bc37 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 16:33:02 +0000 Subject: [PATCH 08/11] chore(main): release 1.706.0 (#9270) * chore(main): release 1.706.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 +++ backend/Cargo.lock | 160 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 139 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac41734dda..5272d8ec38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21) + + +### Features + +* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d)) +* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235)) +* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d)) +* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68)) +* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee)) +* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547)) + + +### Bug Fixes + +* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44)) +* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf)) +* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1)) + ## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1f871d5afb..b1a804bed3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -4315,9 +4315,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.705.0" +version = "1.706.0" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.705.0" +version = "1.706.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.705.0" +version = "1.706.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.705.0" +version = "1.706.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.705.0" +version = "1.706.0" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.705.0" +version = "1.706.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 50c9b7501a..41115db17e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.705.0" +version = "1.706.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.705.0" +version = "1.706.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 89f0fd74c8..e3beab7f1c 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 901abe9fd7..566f9b1e58 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.705.0" +version = "1.706.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6f8755da3a..46c2639ea4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.705.0 + version: 1.706.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9e041bc6ac..5d0acca54f 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.705.0"; +export const VERSION = "v1.706.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index a03db55d0d..e807779ed9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.705.0"; +export const VERSION = "1.706.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4952b96d14..442771f584 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7bfde58794..75d3a2d30f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 238918d44c..2018887843 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.705.0" +wmill = ">=1.706.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9b406df385..c8594a6aed 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.705.0 + version: 1.706.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index b0683e959e..c23a7fbf3f 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.705.0' + ModuleVersion = '1.706.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 561c313aae..4f1ed0e32b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.705.0" +version = "1.706.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 738580f772..ca7b1a859c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.705.0", + "version": "1.706.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4a85819712..51507ebdc7 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.705.0", + "version": "1.706.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 2ce80397aa..0fdf01b5f5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.705.0 +1.706.0 From 72e2c3a6b3e0cb0f5bddf8291ae18bb8cf55ec28 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 20:47:26 +0000 Subject: [PATCH 09/11] fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280) The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls std::os::unix::fs::symlink directly, which doesn't exist on Windows targets. Without a cfg gate, `cargo check --tests` fails on Windows with E0433. Other symlink call sites in this crate (php_executor, bun_executor, rust_executor, etc.) already follow this pattern. Fixes WIN-1972 Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/common.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index aa3c6dbe2a..9864093cd0 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1234,6 +1234,7 @@ mod nsjail_tmp_mount_tests { /// `job_dir` before the resolver runs), the resolver must refuse the /// bind mount and fall back to tmpfs — never bind-mount the symlink /// target into the sandbox as /tmp. + #[cfg(unix)] #[tokio::test] async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() { let tmp = tempfile::tempdir().expect("tempdir"); From 88294182c0d0f47ca21ef532cdec7c47104f2a60 Mon Sep 17 00:00:00 2001 From: Aldrin Jenson Date: Fri, 22 May 2026 02:39:36 -0400 Subject: [PATCH 10/11] Reduce slim image vulnerability surface (#9279) * Reduce slim image vulnerability surface * chore(docker): drop apt-get upgrade -y from slim images apt-get upgrade hurts build reproducibility (same Dockerfile + same commit at different times produces divergent images) and trips hadolint DL3005. The freshness it buys is dominated by simply rebuilding against the periodically-refreshed debian:bookworm-slim base image. The --no-install-recommends and apt-list cleanup wins are kept. --------- Co-authored-by: Ruben Fiszel --- docker/DockerfileSlim | 13 +++++++++---- docker/DockerfileSlimEe | 13 +++++++++---- frontend/src/lib/hubPaths.json | 30 ------------------------------ 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 181d87b380..2bfa883466 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index cc74d14996..d6616b5b97 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 0be40c0bca..902a90c9d1 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,41 +1,11 @@ { - "deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", - "deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", - "deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", - "deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", - "deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", - "deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", - "deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", - "deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", - "deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", - "deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", - "deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", - "deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", - "deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", - "deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", - "deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", - "deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", - "deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", - "deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", - "deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", - "deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill", - "deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill", - "deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill", - "deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", "gitInitRepo": "hub/28219/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack", "slackSuccessHandler": "hub/28220/slack/schedule-success-handler-slack", "teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams", - "teamsErrorHandler_0": "hub/11598/workspace-or-schedule-error-handler-teams", "teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams", "teamsSuccessHandler": "hub/11596/schedule-success-handler-teams", "slackReport": "hub/9084/slack", From 89a2f07218818b95238b4a4484deab3138099672 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 07:09:02 +0000 Subject: [PATCH 11/11] fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974) hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit` to the CLI's hidden `sync git-deploy`. The hub script still does the GPG setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign` locally), but the commit no longer runs in the same `git_push` flow — it runs minutes later inside the CLI after workspace API resolution, zip pull, file extraction, and lockfile autofill. By the time the spawned `git commit` asks gpg-agent for the cached passphrase, the cache state is no longer reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing fails non-interactively with `gpg failed to sign the data`. hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3: the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork branch behavior, the EE deployment-callback `main()` signature is unchanged, and the only min-version check in EE (`is_script_meets_min_version(28103)`) is comfortably below 28230 — so this revert is safe. Forward fix (separate PR): publish a new thin script that, alongside the existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode loopback --passphrase-file` so signing is independent of the agent's cache state. Re-bump past 28231 then. Fixes WIN-1974 Co-Authored-By: Claude Opus 4.7 (1M context) * chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper) This is the script that will be published to hub.windmill.dev once verified on a customer GPG-signed deploy. It replaces hub/28231's agent-cache pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes through the wrapper, which always uses --pinentry-mode loopback (and --passphrase-file when a passphrase exists). Signing no longer depends on gpg-agent having a cached passphrase by the time the CLI's `git commit` runs — which closes WIN-1974. Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this script is uploaded and the new hub id is known. This file is checked in so the diff is reviewable, future bumps have a source of truth, and a CLI regression test can `cat` it for fixture parity. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput A resource field with a `pattern` constraint (e.g. the gpg_key.private_key field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----` prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:` are placeholders the backend resolves at runtime, not the actual string that needs to match the regex. Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom pattern) when the value is one of these references. Required/numeric bounds/array checks still apply since they're shape-level, not regex. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix) hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache pre-warm (which became stale by the time the CLI's `git commit` ran) with a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback` (and `--passphrase-file` when a passphrase exists) on every gpg invocation. Bundled CLI is windmill-cli@1.705.0. Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately killing gpg-agent between GPG setup and `git commit` reproduces the customer's `gpg failed to sign the data` error verbatim under the old flow, and the wrapper signs through it. Holds for passphrase-protected keys, split-subkey [C]+[S] layouts, and unprotected keys. Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical now that 28234 is published. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH The git history (this PR) carries the why; the constant name + value carry the what. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-common/src/workspaces.rs | 2 +- frontend/src/lib/components/ArgInput.svelte | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index dd42d5632a..04461e821e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,7 +157,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28231/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28234/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 839153ad67..ed9d11eaa7 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -406,6 +406,15 @@ if (nullable && emptyString(v)) { error = '' valid && (valid = true) + } else if ( + typeof v === 'string' && + (v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:')) + ) { + // $var/$res/$jsonvar are placeholders resolved at runtime; the literal + // string won't match format constraints (email/ipv4/uuid/custom pattern), + // so format-checking it produces a false-positive "invalid format" error. + error = '' + !valid && (valid = true) } else if (required && (v == undefined || v == null || v === '') && inputCat != 'object') { error = 'Required' valid && (valid = false)