Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker/skill-sandbox/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
README.md
57 changes: 57 additions & 0 deletions docker/skill-sandbox/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
#
# Flowise Skill Sandbox — base image for `DockerBackend`.
#
# Every Skill bash invocation runs inside a fresh, sealed container
# spawned from this image. The Dockerfile is arch-agnostic and works
# on both linux/amd64 and linux/arm64 — your local `docker build`
# produces a native-arch image without any buildx orchestration.
#
# Hardening notes:
# - Non-root `user` (uid 1000, gid 1000) matching the E2B layout.
# - Default WORKDIR is `/home/user`, matching the path layout Skill
# code assumes.
# - PID 1 is `sleep infinity`. We never `docker run` a real command;
# `DockerBackend` calls `docker exec` for each LLM-issued shell
# invocation. The container exists to provide a long-lived
# namespace, nothing more.
#
# Target footprint: ~800 MB final, ~5 minutes warm-cache build.

FROM debian:12-slim

# Core toolchain — `BaseSandbox`'s shell snippets rely on POSIX
# coreutils + bash; everything else is for skill authors.
RUN apt-get update && apt-get install -y --no-install-recommends \
bash coreutils findutils grep sed gawk \
ca-certificates curl gnupg \
python3 python3-pip python3-venv \
jq ripgrep \
poppler-utils pdfgrep \
unzip xz-utils gzip bzip2 \
file less tree \
&& rm -rf /var/lib/apt/lists/*

# Node 20 via NodeSource (Debian's default `nodejs` package is older
# than the floor Skill authors can rely on).
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version && npm --version

# Sanity-check the Python version at build time so we fail loudly
# if Debian ever bumps the default past 3.11.
RUN python3 --version | grep -q '^Python 3\.11\.' \
|| (echo "ERROR: expected Python 3.11.x, got $(python3 --version)" && exit 1)

# Non-root user matching the path layout E2B and the Skill code use.
RUN groupadd -g 1000 user && useradd -m -u 1000 -g 1000 -s /bin/bash user

COPY entrypoint.sh /usr/local/bin/skill-entrypoint.sh
RUN chmod +x /usr/local/bin/skill-entrypoint.sh

USER user
WORKDIR /home/user
RUN mkdir -p /home/user/skills /home/user/output

ENTRYPOINT ["/usr/local/bin/skill-entrypoint.sh"]
56 changes: 56 additions & 0 deletions docker/skill-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Flowise Skill Sandbox — base image

This image is the runtime the `DockerBackend` spawns one container from
per Skill node. Every LLM-issued bash invocation runs inside a fresh,
sealed container so the host filesystem is never modified.

## What's in the image

- Debian 12 (bookworm) slim base.
- Python 3.11 (Debian default; build-time pinned).
- Node 20 (via NodeSource).
- POSIX coreutils + bash, jq, ripgrep, poppler (pdftotext, pdfgrep),
unzip, tree, file, less.
- Non-root user `user` (uid 1000, gid 1000), home `/home/user`.

## Build

```sh
./scripts/build-skill-sandbox.sh
```

Or directly:

```sh
docker build --tag flowise-skill-sandbox:dev docker/skill-sandbox/
```

The Dockerfile is architecture-agnostic — your local Docker builds
a native image for your host (arm64 on M-series Macs, amd64 on x86).

## How the runtime uses it

`DockerBackend.initialize()` creates one container with these defaults:

| Constraint | Default | Env override |
| ------------------- | --------------------------- | ------------------------------ |
| Memory | 512 MiB | `SKILL_DOCKER_MEMORY_MB` |
| CPU | 1.0 | `SKILL_DOCKER_CPUS` |
| PID limit | 128 | `SKILL_DOCKER_PIDS_LIMIT` |
| Network | `none` | `SKILL_DOCKER_NETWORK` |
| Image | `flowise-skill-sandbox:dev` | `SKILL_DOCKER_IMAGE` |
| Lifetime ceiling | 15 min | `SKILL_V2_SANDBOX_LIFETIME_MS` |
| Idle reaper | 5 min | `SKILL_V2_SANDBOX_IDLE_MS` |
| Per-command timeout | 15s | `SKILL_EXEC_TIMEOUT_MS` |

Hardening that is **not** configurable:

- `ReadonlyRootfs: true` everywhere except the anonymous volume mounted
at `/home/user`.
- `CapDrop: ['ALL']`, `no-new-privileges:true`, default seccomp profile.
- `User: '1000:1000'`. Skill code runs as a non-root user.
- `Env: []`. No host environment variables are forwarded into the
container; skills materialise their inputs via `uploadFiles`.

The container's PID 1 is `sleep infinity` — every shell command goes
through `docker exec`, never `docker run`.
7 changes: 7 additions & 0 deletions docker/skill-sandbox/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh
# Flowise Skill Sandbox — PID 1.
#
# We never `docker run` a real command. The Node-side `DockerBackend`
# calls `docker exec` for each LLM-issued bash invocation. This script
# exists solely to keep the container's namespace alive between execs.
exec sleep infinity
12 changes: 10 additions & 2 deletions packages/components/gulpfile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
const { src, dest } = require('gulp')
const { src, dest, parallel } = require('gulp')

function copyIcons() {
return src(['nodes/**/*.{jpg,png,svg}']).pipe(dest('dist/nodes'))
}

exports.default = copyIcons
// Built-in sandbox helpers ship as plain Python scripts that get
// materialised into every Skill sandbox VM. They live next to the
// compiled JS so __dirname-relative reads work in both source (ts-jest)
// and dist contexts. See nodes/tools/Skill/sandbox/builtinHelpers/.
function copyBuiltinHelperScripts() {
return src(['nodes/**/builtinHelpers/scripts/**/*.py']).pipe(dest('dist/nodes'))
}

exports.default = parallel(copyIcons, copyBuiltinHelperScripts)
59 changes: 26 additions & 33 deletions packages/components/nodes/agentflow/Agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,19 @@ import {
getPastChatHistoryImageMessages,
getUniqueImageMessages,
processMessagesWithImages,
processSandboxLinks,
revertBase64ImagesToFileRefs,
normalizeMessagesForStorage,
replaceInlineDataWithFileReferences,
updateFlowState
} from '../utils'
import { SkillSandboxArtifactResolver } from '../../../src/sandbox'
import {
convertMultiOptionsToStringArray,
processTemplateVariables,
configureStructuredOutput,
extractResponseContent
} from '../../../src/utils'
import { sanitizeFileName } from '../../../src/validator'
import { getModelConfigByModelName, MODEL_TYPE } from '../../../src/modelLoader'

interface ITool {
Expand Down Expand Up @@ -1379,9 +1380,31 @@ class Agent_Agentflow implements INode {
}
}

// Replace sandbox links with proper download URLs. Example: [Download the script](sandbox:/mnt/data/dummy_bar_graph.py)
// Replace sandbox links with proper download URLs and lazily
// upload any LLM-written skill sandbox artifacts that the
// final response actually references. Example:
// [Download the script](sandbox:/mnt/data/dummy_bar_graph.py)
// The link points at a file inside the sandbox; this step
// copies the bytes into chat-scoped storage and rewrites the
// link to the API URL the chat UI can resolve.
if (finalResponse.includes('sandbox:/')) {
finalResponse = await this.processSandboxLinks(finalResponse, options.baseURL, options.chatflowid, chatId)
const resolvers = Array.isArray(options.skillSandboxArtifactResolvers)
? (options.skillSandboxArtifactResolvers as SkillSandboxArtifactResolver[])
: []
const { text, fileAnnotations: sandboxFileAnnotations } = await processSandboxLinks(finalResponse, {
baseURL: options.baseURL as string,
chatflowId: options.chatflowid as string,
chatId,
orgId: options.orgId as string,
resolvers
})
finalResponse = text
if (sandboxFileAnnotations.length > 0) {
fileAnnotations = [...fileAnnotations, ...sandboxFileAnnotations]
if (isLastNode && sseStreamer) {
sseStreamer.streamFileAnnotationsEvent(chatId, fileAnnotations)
}
}
}

// If is structured output, then invoke LLM again with structured output at the very end after all tool calls
Expand Down Expand Up @@ -2873,36 +2896,6 @@ class Agent_Agentflow implements INode {
/**
* Processes sandbox links in the response text and converts them to file annotations
*/
private async processSandboxLinks(text: string, baseURL: string, chatflowId: string, chatId: string): Promise<string> {
let processedResponse = text

// Regex to match sandbox links: [text](sandbox:/path/to/file)
const sandboxLinkRegex = /\[([^\]]+)\]\(sandbox:\/([^)]+)\)/g
const matches = Array.from(text.matchAll(sandboxLinkRegex))

for (const match of matches) {
const fullMatch = match[0]
const linkText = match[1]
const filePath = match[2]

try {
// Extract and sanitize filename from the file path (LLM-generated, untrusted)
const fileName = sanitizeFileName(filePath)

// Replace sandbox link with proper download URL
const downloadUrl = `${baseURL}/api/v1/get-upload-file?chatflowId=${chatflowId}&chatId=${chatId}&fileName=${fileName}&download=true`
const newLink = `[${linkText}](${downloadUrl})`

processedResponse = processedResponse.replace(fullMatch, newLink)
} catch (error) {
console.error('Error processing sandbox link:', error)
// If there's an error, remove the sandbox link as fallback
processedResponse = processedResponse.replace(fullMatch, linkText)
}
}

return processedResponse
}
}

module.exports = { nodeClass: Agent_Agentflow }
Loading
Loading