Files
windmill/frontend/src/lib/components/flows/content/FlowInputs.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

306 lines
9.7 KiB
Svelte

<script lang="ts">
import { Alert } from '$lib/components/common'
import ToggleHubWorkspace from '$lib/components/ToggleHubWorkspace.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import FlowScriptPicker from '../pickers/FlowScriptPicker.svelte'
import PickHubScript from '../pickers/PickHubScript.svelte'
import WorkspaceScriptPicker from '../pickers/WorkspaceScriptPicker.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Check, Code, Zap } from 'lucide-svelte'
import SuspendDrawer from './SuspendDrawer.svelte'
import { defaultScripts } from '$lib/stores'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import type { SupportedLanguage } from '$lib/common'
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import { canHavePreprocessor } from '$lib/script_helpers'
interface Props {
failureModule: boolean
preprocessorModule: boolean
shouldDisableTriggerScripts?: boolean
noEditor: boolean
summary?: string | undefined
}
let {
failureModule,
preprocessorModule,
shouldDisableTriggerScripts = false,
noEditor,
summary = $bindable(undefined)
}: Props = $props()
const dispatch = createEventDispatcher()
let kind: 'script' | 'failure' | 'approval' | 'trigger' = $state(
untrack(() => failureModule)
? 'failure'
: summary == 'Trigger'
? 'trigger'
: summary == 'Approval'
? 'approval'
: 'script'
)
let pick_existing: 'workspace' | 'hub' = $state('hub')
let filter = $state('')
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'][]
)
function displayLang(lang: SupportedLanguage | 'docker', kind: string) {
if (preprocessorModule) {
return canHavePreprocessor(lang as SupportedLanguage)
}
if (failureModule || kind === 'trigger') {
if (
[
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql',
'duckdb',
'oracledb'
].includes(lang)
) {
return false
}
return true
}
if (kind === 'script') {
return lang !== 'docker'
}
if (kind === 'approval') {
if (
[
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql',
'duckdb',
'oracledb'
].includes(lang)
) {
return false
}
return true
}
return false
}
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
</script>
<div class="p-4 h-full flex flex-col" id="flow-editor-flow-inputs">
{#if summary == 'Terminate flow'}
<Alert type="info" title="The flow stops here"
>This is an identity step with an early stop that has 'true' for expression</Alert
>
{:else}{#if !failureModule && !preprocessorModule}
<div class="center-center">
<div class="max-w-min">
<ToggleButtonGroup bind:selected={kind}>
{#snippet children({ item })}
<ToggleButton
value="script"
icon={Code}
label="Action"
tooltip="An action script is simply a script that is neither a trigger nor an approval script. Those are the majority of the scripts."
{item}
/>
{#if !shouldDisableTriggerScripts}
<ToggleButton
value="trigger"
icon={Zap}
label="Trigger"
tooltip="Used as a first step most commonly with a state and a schedule to watch for changes on an external system, compute the diff since last time and set the new state. The diffs are then treated one by one with a for-loop."
{item}
/>
{/if}
<ToggleButton
value="approval"
icon={Check}
label="Approval"
tooltip="An approval step will suspend the execution of a flow until it has been approved through the resume endpoints or the approval page by and solely by the recipients of those secret urls."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
{/if}
{#if kind == 'trigger'}
<div class="mt-2"></div>
<Alert title="Trigger scripts" type="info">
Trigger scripts are designed to pull data from an external source and return all of the new
items since the last run, without resorting to external webhooks.<br /><br />
A trigger script is intended to be used with
<a
href="https://www.windmill.dev/docs/core_concepts/scheduling"
target="_blank"
class="text-blue-400">schedules</a
>
and
<a
href="https://www.windmill.dev/docs/core_concepts/resources_and_types#states"
target="_blank"
class="text-blue-400">states</a
>
in order to compare the execution to the previous one and process each new item in a
<a
href="https://www.windmill.dev/docs/flows/flow_loops"
target="_blank"
class="text-blue-400">for loop</a
>. If there are no new items, the flow will be skipped.<br /><br />
By default, adding a trigger will set the schedule to 15 minutes. To see all ways to trigger
a flow, check
<a
href="https://www.windmill.dev/docs/getting_started/triggers"
target="_blank"
class="text-blue-400">Triggering Flows</a
>.
</Alert>
{/if}
{#if kind == 'script' && !noEditor && !preprocessorModule}
<div class="mt-2"></div>
<Alert title="Action Scripts" type="info">
An action script is simply a script that is neither a trigger nor an approval script. Those
are the majority of the scripts.
</Alert>
{/if}
{#if kind == 'approval'}
{#if !noEditor}
<div class="mt-2"></div>
<Alert title="Approval/Prompt Step" type="info">
An approval/prompt step will suspend the execution of a flow until it has been approved
and/or the prompts have been filled in the UI or through the resume endpoints or the
approval page by and solely by the recipients of the secret urls. See details in
'Advanced' -> 'Suspend' settings of the step. A prompt is a specialized approval step with
payload that can be self-approved by the caller.<br /><br />
For further details, visit
<a
href="https://www.windmill.dev/docs/flows/flow_approval"
target="_blank"
class="text-blue-500">Approval/Prompt Steps Documentation</a
>
or
<div class="inline-flex">
<SuspendDrawer text="Approval/Step prompt helpers" />
</div>
</Alert>
{:else}
<a
href="https://www.windmill.dev/docs/flows/flow_approval"
target="_blank"
class="text-blue-500">Approval/Prompt Steps Documentation</a
>
{/if}
{/if}
<h3 class="pb-2 pt-4 flex gap-x-8 flex-wrap">
<div>
Inline new <span class="text-blue-500 dark:text-blue-400"
>{kind == 'script' ? 'action' : kind}</span
>
script
<Tooltip
documentationLink={kind === 'script'
? 'https://www.windmill.dev/docs/flows/editor_components#flow-actions'
: kind === 'trigger'
? 'https://www.windmill.dev/docs/flows/flow_trigger'
: kind === 'approval'
? 'https://www.windmill.dev/docs/flows/flow_approval'
: 'https://www.windmill.dev/docs/getting_started/flows_quickstart#flow-editor'}
>
Embed <span>{kind == 'script' ? 'action' : kind}</span> script directly inside a flow instead
of saving the script into your workspace for reuse. You can always save an inline script to
your workspace later.
</Tooltip>
</div>
<DefaultScripts />
</h3>
{#if noEditor}
<div
class="py-0.5 text-2xs {summary == undefined || summary == ''
? 'text-red-600'
: 'text-ternary'}"
>Pick a summary first, it will be used to create a separate file whose name will be derived
from the summary</div
>
<input class="w-full" type="text" bind:value={summary} placeholder="Summary" />
<div class="pb-2"></div>
{/if}
<div class="flex flex-row flex-wrap gap-2" id="flow-editor-action-script">
{#each langs.filter((lang) => customUi?.languages == undefined || customUi?.languages?.includes(lang?.[1])) as [label, lang] (lang)}
{#if displayLang(lang, kind)}
<FlowScriptPicker
id={`flow-editor-action-script-${lang}`}
disabled={noEditor && (summary == undefined || summary == '')}
{label}
lang={lang == 'docker' ? 'bash' : lang}
on:click={() => {
dispatch('new', {
language: lang == 'docker' ? 'bash' : lang,
kind,
subkind: lang == 'docker' ? 'docker' : preprocessorModule ? 'preprocessor' : 'flow',
summary
})
}}
/>
{/if}
{/each}
</div>
{#if !failureModule && !preprocessorModule && customUi?.aiSandbox != false}
<h3 class="pb-2 pt-4">AI Sandbox</h3>
<div class="flex flex-row flex-wrap gap-2">
<FlowScriptPicker
label="Claude Code"
lang="claudesandbox"
on:click={() => {
dispatch('new', {
language: 'bun',
kind,
subkind: 'claudesandbox',
summary
})
}}
/>
</div>
{/if}
<h3 class="mb-2 mt-6"
>Use pre-made <span class="text-blue-500 dark:text-blue-400"
>{kind == 'script' ? 'action' : kind}</span
> script</h3
>
{#if pick_existing == 'hub'}
<PickHubScript bind:filter {kind} on:pick>
<ToggleHubWorkspace bind:selected={pick_existing} />
</PickHubScript>
{:else}
<WorkspaceScriptPicker displayLock bind:filter {kind} on:pick>
<ToggleHubWorkspace bind:selected={pick_existing} />
</WorkspaceScriptPicker>
{/if}
{/if}
</div>