Files
windmill/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte
T
Ruben Fiszel 1727271e19 feat: sandboxed daemonless container runtime via '# sandbox <image>' (#9453)
* feat: add sandboxed docker v2 runtime via '# docker <image>'

Run a container image as a subprogram of the job's own nsjail sandbox:
extract the image rootfs with podman (rootless) and run it chrooted inside the
job's nsjail, so the container inherits the job's confinement and is safe under
nsjail / for untrusted code. Selected by '# docker <image>'; a bare '# docker'
keeps the v1 (dind) path untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: default to daemonless docker (drop dind from compose, allow docker on cloud)

docker-compose no longer ships the dind sidecar (v2 is daemonless: podman + nsjail
in the worker); removed the dind service, DOCKER_HOST env, depends_on and volume.
Removed the language-picker guard that blocked Docker scripts on the multi-tenant
platform, now that v2 makes docker safe to run sandboxed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: select sandboxed container via # sandbox <image>; add pull policy + size guards

- Surface moved from '# docker <image>' to '# sandbox <image>' (groups under the
  sandbox annotation; '# docker' stays v1-only, '# sandbox' stays nsjail-bash).
- SANDBOX_IMAGE_PULL_POLICY (default 'newer') so moving tags don't go stale.
- SANDBOX_IMAGE_MAX_SIZE_MB rejects oversized images before extraction.
- SANDBOX_IMAGE_CACHE_MAX_MB best-effort LRU eviction of podman's image store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): support # volume, honor nsjail tmp instance settings, v2 docker template

- Thread shared_mount into the sandbox container nsjail config so '# volume' mounts
  (and the same-worker /tmp/shared folder) apply inside the container.
- Use resolve_nsjail_tmp_mount_block for the container's /tmp so it honors the same
  nsjail_tmp_backing / nsjail_tmpfs_size_mb instance settings as other nsjail jobs.
- docker-compose comment + the editor's Docker template now use '# sandbox <image>'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): make image size/cache/pull-policy UI instance settings

Convert SANDBOX_IMAGE_* from worker env vars to DB-backed instance settings
(sandbox_image_max_size_mb, sandbox_image_cache_max_mb, sandbox_image_pull_policy),
hot-reloaded via the same mechanism as nsjail_tmpfs_size_mb and configurable in
#superadmin-settings. No worker restart needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): windmill-managed registry — default registry + private auth

Two new instance settings:
- sandbox_image_default_registry: prepended to unqualified image refs (alpine ->
  <registry>/alpine); fully-qualified refs untouched.
- sandbox_registry_auth: docker/podman auth.json blob written to a per-job authfile
  (0600, removed with the job) and passed to podman --authfile for private registries.
Both hot-reloaded and configurable in #superadmin-settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): protobuf-safe proto_str escaper, atomic 0600 authfile, registry tests

Addresses local-review P2s: proto_str now emits valid protobuf octal escapes for
control/non-ASCII bytes (not Rust \u{..} that nsjail would reject); the registry
authfile is created 0600 atomically (no world-readable window); add a
registry_qualified table test + a non-ASCII proto_str case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P0 — deliver image env via nsjail envar:, never the launcher process env

CI review (P0): the image's OCI Env (attacker-controlled keys+values) was applied to
the nsjail launcher process via .envs(), so a hostile image could set LD_PRELOAD/
LD_LIBRARY_PATH/LD_AUDIT on nsjail itself and execute code as the worker outside the
jail. Now the image env is rendered as proto-escaped 'envar:' directives (child-only)
and nsjail's process env carries only windmill-trusted keys (reserved vars + proxy).
Also: warn instead of silently bypassing the size guard on inspect failure; reset the
eviction guard via a Drop guard (no stuck flag on panic/early-return). +render_envars test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): P0 symlink-write escape via rootfs script; P1 redact registry-auth logging

CI review:
- P0 (Codex): the body was written into the image-controlled rootfs as
  .windmill_docker_main.sh via write_file (follows symlinks) — a hostile image could
  plant that path as a symlink to a host file and capture the worker's write before
  nsjail starts. Now the body is passed straight to 'sh -c <body> sh <args>'; no file
  is written into the rootfs at all.
- P1 (Codex): sandbox_registry_auth flowed through the generic setting loader which
  logs the value (raw auth.json credentials). Replaced with a secret-aware reload that
  loads directly and logs only a redacted 'configured=' message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): redact sandbox_registry_auth in instance-settings write log too

The settings API also logs 'Set global setting <key> to <value>' via format_setting_value;
add sandbox_registry_auth to SENSITIVE_SETTINGS so the credential is redacted there as
well as on reload.

* fix(sandbox): don't silently disable cache eviction on podman images parse error

Re-review (cubic/Claude P2): serde_json::from_slice(...).unwrap_or_default() meant any
parse hiccup (e.g. podman omitting Size/Created via omitempty for a zero value, or
schema drift) silently degraded to an empty Vec and disabled eviction with no log.
Now Size/Created are #[serde(default)] (a missing omitempty key -> 0, not a whole-array
parse failure) and a real parse error warns + breaks instead of being swallowed.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:35:51 +00:00

529 lines
15 KiB
Svelte

<script module lang="ts">
let cachedOwners: Record<string, string[]> = {}
let cachedIntegrations: string[] = []
</script>
<script lang="ts">
import { sendUserToast } from '$lib/toast'
import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import {
defaultScripts,
enterpriseLicense,
hubBaseUrlStore,
userStore,
workspaceStore
} from '$lib/stores'
import type { SupportedLanguage } from '$lib/common'
import { createEventDispatcher, getContext, onDestroy, onMount, untrack } from 'svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import { type Script, type ScriptLang, type HubScriptKind } from '$lib/gen'
import ListFiltersQuick from '$lib/components/home/ListFiltersQuick.svelte'
import { ExternalLink, Folder, User, X } from 'lucide-svelte'
import type { FlowEditorContext } from '../../flows/types'
import { fade } from 'svelte/transition'
import { flip } from 'svelte/animate'
import { Button } from '$lib/components/common'
import { SettingsIcon } from 'lucide-svelte'
import DefaultScriptsInner from '$lib/components/DefaultScriptsInner.svelte'
import GenAiQuick from './GenAiQuick.svelte'
import FlowToplevelNode from '../pickers/FlowToplevelNode.svelte'
import { copilotInfo } from '$lib/aiStore'
import {
canHavePreprocessor,
canHaveTrigger,
canHaveApproval,
canHaveFailure
} from '$lib/script_helpers'
const dispatch = createEventDispatcher()
interface Props {
summary?: string | undefined
filter?: string
disableAi?: boolean
preFilter?: 'all' | 'workspace' | 'hub'
funcDesc: string
owners?: string[]
loading?: boolean
kind: 'trigger' | 'script' | 'preprocessor' | 'failure' | 'approval'
selectedKind?: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
displayPath?: boolean
refreshCount?: number
}
let {
summary = undefined,
filter = $bindable(''),
disableAi = false,
preFilter = 'hub',
funcDesc,
owners = $bindable([]),
loading = $bindable(false),
kind,
selectedKind = kind,
displayPath = false,
refreshCount = 0
}: Props = $props()
if ($workspaceStore && cachedOwners?.[$workspaceStore]) {
owners = cachedOwners[$workspaceStore]
}
type HubCompletion = {
path: string
summary: string
id: number
version_id: number
ask_id: number
app: string
kind: HubScriptKind
}
let lang: ScriptLang | undefined = $state(undefined)
let filteredWorkspaceItems: (Script & { marked?: string })[] = $state([])
let hubCompletions: HubCompletion[] = $state([])
const { insertButtonOpen } = getContext<FlowEditorContext>('FlowEditorContext')
let selected: { kind: 'owner' | 'integrations'; name: string | undefined } | undefined =
$state(undefined)
let integrations: string[] = $state(cachedIntegrations)
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
function displayLang(
lang: SupportedLanguage | 'docker',
kind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
if (kind == 'trigger') {
return canHaveTrigger(lang as SupportedLanguage)
} else if (kind == 'script') {
return true
} else if (kind == 'approval') {
return canHaveApproval(lang as SupportedLanguage)
} else if (kind == 'flow') {
return false
} else if (kind == 'preprocessor') {
return canHavePreprocessor(lang as SupportedLanguage)
} else if (kind == 'failure') {
return canHaveFailure(lang as SupportedLanguage)
}
}
async function onGenerate() {
if (!$copilotInfo.enabled) {
sendUserToast(
'Windmill AI is not enabled, you can activate it in the workspace settings',
true
)
return
}
console.log('ongenerate', selectedKind, lang, funcDesc)
dispatch('new', {
kind: selectedKind,
inlineScript: {
language: lang,
kind: selectedKind,
subkind: 'flow',
summary,
instructions: funcDesc
}
})
}
let openScriptSettings = $state(false)
let selectedByKeyboard = $state(0)
function onSelectedKindChange(
_selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
selectedByKeyboard = 0
}
let inlineScripts: [string, SupportedLanguage | 'docker'][] = $state([])
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql', 'oracledb']
function computeInlineScriptChoices(
funcDesc: string,
selected: { kind: 'owner' | 'integrations'; name: string | undefined } | undefined,
preFilter: 'all' | 'workspace' | 'hub',
selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) {
if (!selected && preFilter == 'all') {
inlineScripts = langs.filter((lang) => {
return (
(customUi?.languages == undefined || customUi?.languages?.includes(lang?.[1])) &&
(funcDesc?.length == 0 ||
lang?.[0]?.toLowerCase()?.includes(funcDesc?.toLowerCase())) &&
displayLang(lang?.[1], selectedKind)
)
})
return
}
}
inlineScripts = []
}
const allToplevelNodes: [string, string][] = [
['For loop', 'forloop'],
['While loop', 'whileloop'],
['Branch to one', 'branchone'],
['Branch to all', 'branchall'],
...(customUi?.aiAgent != false ? ([['AI Agent', 'aiagent']] as [string, string][]) : [])
]
let topLevelNodes: [string, string][] = $state([])
function computeToplevelNodeChoices(funcDesc: string, preFilter: 'all' | 'workspace' | 'hub') {
if (funcDesc.length > 0 && preFilter == 'all' && kind == 'script') {
topLevelNodes = allToplevelNodes.filter((node) =>
node[0].toLowerCase().startsWith(funcDesc.toLowerCase())
)
} else {
topLevelNodes = []
}
}
function onPrefilterChange(preFilter: 'all' | 'workspace' | 'hub') {
if (preFilter == 'workspace') {
hubCompletions = []
} else if (preFilter == 'hub') {
filteredWorkspaceItems = []
}
selectedByKeyboard = 0
}
let scrollable: HTMLElement | undefined = $state()
function onKeyDown(e: KeyboardEvent) {
let length =
topLevelNodes?.length +
inlineScripts.length +
aiLength +
filteredWorkspaceItems.length +
hubCompletions.length
if (e.key === 'ArrowDown') {
selectedByKeyboard = (selectedByKeyboard + 1) % length
scrollable?.scrollTo({ top: selectedByKeyboard * 32, behavior: 'smooth' })
e.preventDefault()
} else if (e.key === 'ArrowUp') {
selectedByKeyboard = (selectedByKeyboard - 1 + length) % length
scrollable?.scrollTo({ top: selectedByKeyboard * 32, behavior: 'smooth' })
e.preventDefault()
}
}
onMount(() => {
$insertButtonOpen = true
})
onDestroy(() => {
$insertButtonOpen = false
})
let langs = $derived(
processLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages))
.map((l) => [defaultScriptLanguages[l], l])
.filter(
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x[1])
) as [string, SupportedLanguage | 'docker'][]
)
$effect(() => {
selectedKind
untrack(() => onSelectedKindChange(selectedKind))
})
$effect(() => {
;[funcDesc, preFilter]
untrack(() => computeToplevelNodeChoices(funcDesc, preFilter))
})
$effect(() => {
;[funcDesc, selected, preFilter, selectedKind]
untrack(() => computeInlineScriptChoices(funcDesc, selected, preFilter, selectedKind))
})
$effect(() => {
preFilter
untrack(() => onPrefilterChange(preFilter))
})
let aiLength = $derived(
funcDesc?.length > 0 && !disableAi && selectedKind != 'flow' && preFilter == 'all' ? 2 : 0
)
</script>
<svelte:window onkeydown={onKeyDown} />
<div class="flex flex-row grow min-w-0 divide-x relative bg-surface-tertiary rounded-md">
{#if selectedKind != 'preprocessor'}
<div class="h-full overflow-auto p-2 w-36 shrink-0 gap-1 flex flex-col">
{#if ['script', 'trigger', 'approval', 'preprocessor', 'failure'].includes(selectedKind)}
{#if (preFilter === 'all' && owners.length > 0) || preFilter === 'workspace'}
{#if preFilter !== 'workspace'}
<div class="pb-0 text-2xs font-normal text-secondary ml-2">Folders</div>
{/if}
{#if owners.length > 0}
{#each owners as owner (owner)}
<div
in:fade={{ duration: 50 }}
animate:flip={{ duration: 100 }}
class="w-full px-0.5"
>
<Button
selected={owner === selected?.name}
onClick={() => {
selected = selected?.name == owner ? undefined : { kind: 'owner', name: owner }
}}
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start"
startIcon={{
icon: owner.startsWith('f/') ? Folder : User,
props: { width: 14, height: 14 }
}}
>
{owner.slice(2)}
</Button>
</div>
{/each}
<div class="pb-1"></div>
{:else}
<div class="text-2xs text-primary font-normal text-center py-3 px-3 items-center">
No items found.
</div>
{/if}
{/if}
{#if preFilter === 'hub' || preFilter === 'all'}
{#if preFilter == 'all'}
<div class="pb-0 text-2xs font-normal text-secondary ml-2 pt-1">Integrations</div>
{/if}
<ListFiltersQuick
on:selected={() => {
filteredWorkspaceItems = []
selectedByKeyboard = 0
}}
filters={integrations}
bind:selectedFilter={selected}
resourceType
/>
{#if !selected && customUi?.suggestIntegration != false}
<div class="pl-2 py-1">
<a
href={`${$hubBaseUrlStore}?suggest_integration=true`}
target="_blank"
class="text-2xs flex flex-row items-center gap-1"
>Suggest integration <ExternalLink class="size-3" />
</a>
</div>
{/if}
{/if}
{:else if selectedKind === 'flow'}
{#if owners.length > 0}
{#each owners as owner (owner)}
<div in:fade={{ duration: 50 }} animate:flip={{ duration: 100 }}>
<Button
selected={owner === selected?.name}
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start"
startIcon={{
icon: owner.startsWith('f/') ? Folder : User
}}
onClick={() => {
selected = selected?.name == owner ? undefined : { kind: 'owner', name: owner }
}}
>
{owner.slice(2)}
</Button>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
<div
bind:this={scrollable}
id="flow-editor-flow-atoms"
class="h-full overflow-auto grow min-w-0 p-2 gap-1 flex flex-col"
>
{#if kind == 'script'}
{#each topLevelNodes as [label, kind], i (label)}
<FlowToplevelNode
on:click={() => {
dispatch('new', { kind })
}}
{label}
selected={selectedByKeyboard === i}
/>
{/each}
{/if}
{#if inlineScripts?.length > 0}
<div class="pb-0 flex flex-row items-center gap-2">
<div class="text-2xs font-normal text-secondary ml-2"
>New {selectedKind != 'script' ? selectedKind + ' ' : ''}script</div
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
{#if !openScriptSettings}
<Button
onClick={() => (openScriptSettings = true)}
startIcon={{ icon: SettingsIcon }}
unifiedSize="sm"
variant="subtle"
title="Edit global default scripts"
btnClasses="-my-3"
/>
{:else}
<Button
onClick={() => (openScriptSettings = false)}
startIcon={{ icon: X }}
variant="accent"
unifiedSize="sm"
btnClasses="-my-3"
>
Close
</Button>
{/if}
{/if}
</div>
{#if openScriptSettings}
<div class="p-2">
<DefaultScriptsInner small />
</div>
{/if}
{#each inlineScripts as [label, lang], i (lang)}
<FlowScriptPickerQuick
eeRestricted={!$enterpriseLicense && enterpriseLangs.includes(lang)}
selected={selectedByKeyboard === i + topLevelNodes.length}
{enterpriseLangs}
{label}
lang={lang == 'docker' ? 'bash' : lang}
on:click={() => {
dispatch('new', {
kind: selectedKind,
inlineScript: {
language: lang == 'docker' ? 'bash' : lang,
kind: selectedKind,
subkind:
lang == 'docker'
? 'docker'
: selectedKind == 'preprocessor'
? 'preprocessor'
: 'flow',
summary
}
})
}}
/>
{/each}
{/if}
{#if !disableAi && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && (selectedKind == 'script' || selectedKind == 'trigger') && preFilter == 'all'}
<ul class="transition-all">
<li
><GenAiQuick
{funcDesc}
lang="TypeScript"
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length}
on:click={() => {
lang = 'bun'
onGenerate()
}}
/>
</li>
<li>
<GenAiQuick
{funcDesc}
lang="Python"
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length + 1}
on:click={() => {
lang = 'python3'
onGenerate()
}}
/>
</li>
</ul>
{/if}
{#if (!selected || selected?.kind === 'owner') && (preFilter === 'workspace' || preFilter === 'all')}
{#if !selected && (preFilter !== 'workspace' || funcDesc?.length > 0)}
<div class="pt-2 pb-0 text-2xs font-normal text-secondary ml-2">Workspace</div>
{/if}
{#await import('../pickers/WorkspaceScriptPickerQuick.svelte') then Module}
<Module.default
bind:owners={
() => owners,
(v) => {
$workspaceStore && (cachedOwners[$workspaceStore] = v)
owners = v
}
}
bind:ownerFilter={selected}
bind:filteredWithOwner={filteredWorkspaceItems}
{filter}
kind={selectedKind}
selected={selectedByKeyboard - inlineScripts?.length - aiLength - topLevelNodes.length}
on:pickScript
on:pickFlow
{displayPath}
{refreshCount}
/>
{/await}
<div class="pb-1"></div>
{/if}
{#if selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false}
<div class="pb-0 text-2xs font-normal text-secondary ml-2">AI Sandbox</div>
<FlowScriptPickerQuick
eeRestricted={false}
selected={false}
enterpriseLangs={[]}
label="Claude Code"
lang="claudesandbox"
on:click={() => {
dispatch('new', {
kind: selectedKind,
inlineScript: {
language: 'bun',
kind: selectedKind,
subkind: 'claudesandbox',
summary
}
})
}}
/>
{/if}
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
{#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')}
{#if !selected && preFilter !== 'hub'}
<div class=" pb-0 text-2xs font-normal text-secondary ml-2">Hub</div>
{/if}
{#await import('../pickers/PickHubScriptQuick.svelte') then Module}
<Module.default
bind:items={hubCompletions}
bind:filter
bind:apps={
() => integrations,
(v) => {
cachedIntegrations = v
integrations = v
}
}
appFilter={selected?.name}
kind={selectedKind}
selected={selectedByKeyboard -
inlineScripts?.length -
aiLength -
filteredWorkspaceItems?.length -
topLevelNodes.length}
on:pickScript
bind:loading
{displayPath}
{refreshCount}
/>
{/await}
{/if}
{/if}
</div>
</div>