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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ lint: lint-tools
@echo "Running gosec..."
GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -quiet -exclude-dir=vendor ./...
@echo "Running shellcheck..."
@scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' \) -not -path './vendor/*' | sort)"; \
@scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path './extensions/*/bin/*' \) -not -path './vendor/*' | sort)"; \
if [ -z "$$scripts" ]; then \
echo "No shell scripts found"; \
else \
Expand Down Expand Up @@ -222,7 +222,7 @@ lint-report: lint-tools
@GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -exclude-dir=vendor ./... > $(REPORTS_DIR)/gosec.txt 2>&1 || true
@echo " -> $(REPORTS_DIR)/gosec.txt"
@echo "Running shellcheck..."
@scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' \) -not -path './vendor/*' | sort)"; \
@scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path './extensions/*/bin/*' \) -not -path './vendor/*' | sort)"; \
if [ -z "$$scripts" ]; then \
echo "No shell scripts found" > $(REPORTS_DIR)/shellcheck.txt; \
else \
Expand Down
25 changes: 19 additions & 6 deletions docs/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,20 @@ extensions/
│ ├── install.sh
│ └── feature-entrypoint.d/
│ └── setup.sh # Runs for ALL tools
└── devtools/
├── devtools/
│ ├── spec.yaml
│ └── install.sh
└── vnc/
├── spec.yaml
└── install.sh
├── install.sh
└── bin/ # Extension-less scripts installed onto PATH
└── vnc-supervisor
```

`spec.yaml` is the extension manifest. `install.sh`,
`gateway-allowlist.conf`, `entrypoint.d/`, `feature-entrypoint.d/`,
`templates/`, and `skills/` remain sibling files rather than fields in the
manifest. The in-container build shell scripts under
`templates/`, `bin/`, and `skills/` remain sibling files rather than fields in
the manifest. The in-container build shell scripts under
`runtime-assets/build-scripts/` read the metadata they need (feature/tool
enablement, `priority`, `needsRoot`, `aptPackages`, `failOnInstallError`)
straight from `spec.yaml` (falling back to `spec.json`) with `yq`.
Expand Down Expand Up @@ -530,8 +535,15 @@ without help from the tool spec.
|------|---------|
| `install.sh` | Installation script (runs as root if `needsRoot: true`) |
| `feature-entrypoint.d/*.sh` | Scripts sourced at startup for ALL tools |
| `bin/` | Extension-less runtime scripts the feature installs into the image; covered by `make lint`'s shellcheck pass and by `make lint-changed` |
| `skills/` | Agent skills composed into the tool's skills directory when the feature is enabled |

`bin/` holds scripts destined for a `PATH` directory in the image, where a
`.sh` suffix would be wrong. `install.sh` must place them with an explicit mode
(`install -D -m 755 …`): source modes do not survive the embedded-asset
extraction that package installs use, so only `install.sh` itself is
mode-normalized by the build.

### Feature Selection

Configure which features to install via `~/.config/enclave/config.json` (global) or `~/.config/enclave/projects/<hash>/config.json` (project):
Expand All @@ -549,9 +561,9 @@ Opt-in features require an explicit list; additive-only entries do not change th

The next time you run `./enclave --rebuild`, only the specified features will be installed.

**Available features:** `devtools`, `github-cli`, `gitlab-cli`, `node-dev`, `playwright`, `python-dev`, `debug-tools`, `shell-extras`
**Available features:** `devtools`, `github-cli`, `gitlab-cli`, `node-dev`, `playwright`, `python-dev`, `debug-tools`, `shell-extras`, `vnc`

**Opt-in features (not installed unless explicitly listed):** `debug-tools`, `gitlab-cli`, `playwright`, `shell-extras`
**Opt-in features (not installed unless explicitly listed):** `debug-tools`, `gitlab-cli`, `playwright`, `shell-extras`, `vnc`

### Installation Order

Expand Down Expand Up @@ -602,6 +614,7 @@ resolved port appears in the printed `openUrl` and in `enclave ps`.
| `node-dev` | 70 | Node.js dev tools: typescript, eslint, prettier |
| `python-dev` | 70 | Python dev tools: black, ruff, mypy, pytest |
| `playwright` | 75 | Playwright browsers and MCP server for UI testing (opt-in) |
| `vnc` | 75 | Contained GUI (Xvnc + Chromium) served over VNC, reachable by attaching any VNC client to the published RFB port (opt-in) |
| `debug-tools` | 80 | Debug tools: gdb, strace, ltrace, tcpdump (opt-in) |
| `shell-extras` | 90 | Shell enhancements: zsh, oh-my-zsh, direnv (opt-in) |

Expand Down
21 changes: 21 additions & 0 deletions docs/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ boundary.
The experimental QEMU backend has no restricted-egress implementation. It runs
with unrestricted networking and without gateway-side HTTP secret release.

## Published ports and contained displays

The allowlist governs egress only. Ports published by `-p`, by a tool profile,
or by an enabled feature open an inbound path into the session and are outside
that policy. Published ports bind the host loopback by default, but under
network isolation they are bound on the session's gateway container, which sits
on a shared Docker bridge: a service listening on all interfaces inside the
namespace is also reachable from other containers on that bridge, including
other sessions' gateways. Whatever the service itself enforces is the only gate
at that layer.

A published port that carries an interactive display rather than data widens
this further. The `vnc` feature serves the session's X display over RFB, so a
client that reaches the port and passes VncAuth drives a real browser running as
the sandbox user, with the session's filesystem reachable through it and the X
clipboard bridged in both directions. Treat the per-session VNC password as the
whole boundary and keep it to trusted local viewers. A viewer also cannot vouch
for what the streamed page shows; the content is agent-influenced. See the
[vnc feature README](../../extensions/features/vnc/README.md) for the
feature-specific residual risks.

## Secrets

Host environment variables are not passed unless declared by an enabled
Expand Down
143 changes: 143 additions & 0 deletions extensions/features/vnc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# vnc feature

Opt-in feature that gives a session a **contained GUI**: a virtual X display
(TigerVNC's `Xvnc`) running a fullscreened Chromium, served over **VNC (RFB)**.
The raw RFB port is published on the host loopback, so you can attach any VNC
Comment thread
sdirix marked this conversation as resolved.
client of your choice.

Enable it:

```bash
enclave --features +vnc …
```

The feature needs a base image whose archive ships Chromium as a deb. The
default Debian base does; on an Ubuntu base, which only ships Chromium as a
snap, the install fails and the build stops with an error naming this feature.

## Connecting a VNC client

The RFB port (container `5900`) is published with an OS-assigned host port on
the loopback interface, so concurrent sessions get distinct ports. The session
prints the resolved `vnc://localhost:<port>` at startup; `enclave ps --json`
reports it too (look for container port `5900`). Read the per-session password
out of the session and point your client at it:

```bash
enclave exec --name <session> -- cat /tmp/enclave-vnc/vnc-password
vncviewer 127.0.0.1:<published-host-port>
```

## What runs in the container

`commands.startup` launches `vnc-supervisor` (installed to `/usr/local/bin`)
as the sandbox user. It keeps three components alive with per-component restart
loops, logging to `/tmp/enclave-vnc/log/`:

1. **Xvnc**: virtual X display `:99`, RFB server on the published container
port `5900`, VncAuth required. It listens on all container interfaces
(no `-localhost`) so the host's loopback-published port reaches it.
2. **matchbox-window-manager**: fullscreens every window (kiosk-style).
3. **Chromium**: headful on the virtual display. It starts on a local
waiting page and stays there until a page is opened. When
`$ENCLAVE_VNC_URL` is set, a one-shot watcher probes it and forwards it
into the running browser once its TCP port accepts connections. Loading the
URL directly would instead park the display on a connection-refused error
page whenever the target server starts later than the stack. Sessions can
also drive the browser on demand via `vnc-open`, which is how a consuming
feature opens a URL it only knows at runtime.

Both the supervisor and `vnc-open` launch Chromium through the shared
`/usr/local/bin/vnc-chromium` wrapper, so the browser behaves the same however
it was started; that script's header documents the switches it sets and why.

The feature entrypoint additionally exports `DISPLAY=:99` and
`BROWSER=/usr/local/bin/vnc-open`, and `install.sh` registers `vnc-open` as
the image-wide `x-scheme-handler` for http/https (desktop entry plus
`/etc/xdg/mimeapps.list`), so X clients and "open in browser" flows land on
the contained display (`vnc-open`'s header explains why the scheme-handler
registration, not just `$BROWSER`, is load-bearing). `vnc-open <url>` reuses
the supervisor's Chromium profile, waiting briefly for its singleton if the
stack is still booting, so URLs open in the running browser instead of racing
it, and logs to `/tmp/enclave-vnc/log/vnc-open.log`.

A set `DISPLAY` is also how many tools decide a GUI is available, so with this
feature enabled `gpg` pinentry, `SSH_ASKPASS`, and `GIT_ASKPASS` prompts render
on the contained display rather than in the terminal. Check the VNC client if
an interactive command appears to hang.

## Access control

The supervisor generates a random password on first start and enforces it at
the RFB layer (VncAuth), so Xvnc demands it from every client. It writes two
copies:

- obfuscated auth file: `/tmp/enclave-vnc/rfb-passwd` (Xvnc)
- plaintext: `/tmp/enclave-vnc/vnc-password` (mode 0600)

Holding that password is what grants control of the display, and nothing else
does. It is generated per session, so it reaches exactly one session's display
and no other — which is why the (untrusted) agent knowing it is harmless, and
why reading it out of the session is safe.

The plaintext path is the **integration contract** for a trusted host-side
viewer: `/tmp/enclave-vnc/vnc-password` inside the session, alongside the
container port `5900` binding that `enclave ps --json` reports. The path is the
contract; how a viewer reads it is up to the backend it drives. `enclave exec`
always allocates a TTY, so it serves the interactive flow above but not a
headless one — a non-interactive viewer needs a backend-level read (for Docker,
`docker exec`) until the CLI grows a non-TTY exec.

## Configuration

Environment variables read by the supervisor (set via a consuming feature's
`environment.variables` or `-e`):

| Variable | Default | Meaning |
|----------|---------|---------|
| `ENCLAVE_VNC_URL` | unset | Optional URL auto-forwarded into the browser once its port is reachable. Left unset, the display stays on the waiting page and sessions open pages on demand via `vnc-open`. |
| `ENCLAVE_VNC_URL_WAIT_SECONDS` | `300` | How long that forward waits for the URL's port before giving up and logging. |
| `ENCLAVE_VNC_GEOMETRY` | `1600x1000` | Initial display size (a resize-capable client can change it) |
| `ENCLAVE_VNC_DISPLAY` | `:99` | X display number |

The RFB port is not configurable: it must match the `ports:` declaration in
`spec.yaml` (container port `5900`), so the supervisor hardcodes it.

A consuming feature should leave `ENCLAVE_VNC_URL` unset whenever the page it
wants is only determined at runtime, and call `vnc-open` with the full URL
instead. Auto-forwarding a bare server root in that situation lands the display
on a default view, which can clobber whatever state the intended URL would have
selected.

## Troubleshooting

The entrypoint starts the supervisor with its stdout and stderr discarded, so
the files under `/tmp/enclave-vnc/log/` are the only record. Start with
`supervisor.log` (startup, auth-file generation, URL forwarding), then
`xvnc.log`, `wm.log`, `browser.log`, and `vnc-open.log` for the individual
components.

## Residual risks

[Security boundaries](../../../docs/security/README.md#published-ports-and-contained-displays)
covers how a published display fits the overall threat model. Feature-specific:

- Holding the password is sufficient to drive the display, so keep it to
trusted local viewers.
- The host publish is loopback-only, but Xvnc listens on all interfaces
inside the container's network namespace. Under network isolation that
namespace belongs to the session's gateway container on a shared Docker
bridge, so other containers on that bridge (including other sessions'
gateways) can reach the RFB port directly, with VncAuth as the only gate.
- A human can be phished by what the streamed page *shows*. A viewer cannot
vouch for the session's content.
- Clipboard crossing: Xvnc syncs the display's X selections with the RFB
clipboard natively (`SendCutText`/`AcceptCutText`/`SetPrimary`/`SendPrimary`,
all on by default), so any authenticated RFB client can exchange clipboard
text with the session. Nothing in this feature gates that. A viewer built on
top of it has to mediate the clipboard itself if it wants to.

## Cost

Chromium + X + VNC + fonts add roughly 600 MB to the image and a persistent
browser process to the session, hence `defaultEnabled: false`.
38 changes: 38 additions & 0 deletions extensions/features/vnc/bin/vnc-chromium
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/bash
# Copyright (C) 2026 EclipseSource GmbH and others.
#
# This program and the accompanying materials are made available under the
# terms of the MIT License, which is available in the project root.
#
# SPDX-License-Identifier: MIT

# vnc-chromium is the single Chromium invocation shared by vnc-supervisor and
# vnc-open, so the browser behaves the same no matter which path started it.
#
# The container is the sandbox: Chromium's own sandbox needs unprivileged user
# namespaces, which the default Docker seccomp profile blocks, so it runs with
# --no-sandbox and everything it loads egresses via the session gateway. The
# background-networking/component-update/sync switches keep Chromium's own
# phone-home traffic (component updater, safe browsing, optimization guide)
# from hammering gateway-denied domains and filling the network log.
#
# Arguments are passed after `--` so a caller-supplied string that happens to
# start with a dash lands as a URL rather than as a Chromium switch: vnc-open,
# the only caller, is reachable as $BROWSER and as the http/https scheme
# handler, so its argument is not always under this feature's control.
set -u

exec chromium \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
--disable-background-networking \
--disable-component-update \
--disable-sync \
--no-first-run \
--no-default-browser-check \
--hide-crash-restore-bubble \
--password-store=basic \
--start-maximized \
--user-data-dir=/tmp/enclave-vnc/chromium \
-- "$@"
55 changes: 55 additions & 0 deletions extensions/features/vnc/bin/vnc-open
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash
# Copyright (C) 2026 EclipseSource GmbH and others.
#
# This program and the accompanying materials are made available under the
# terms of the MIT License, which is available in the project root.
#
# SPDX-License-Identifier: MIT

# vnc-open <url> opens a URL in the session's contained (VNC) browser. It
# shares the supervisor's Chromium profile, so a running instance adopts the
# URL as a new (fullscreened) window instead of a second browser starting.
#
# Two paths route here so "open in browser" flows land on the contained
# display instead of failing headless (canonical rationale; install.sh, the
# desktop entry, and the README point back at this comment):
# - the feature entrypoint exports BROWSER=/usr/local/bin/vnc-open
# - install.sh registers this script as the x-scheme-handler for http/https,
# which xdg-open (and the npm "open" package) resolves *before* $BROWSER.
# The apt-installed chromium.desktop would otherwise win and crash
# sandbox-less, silently dropping the URL.
#
# The stack may still be booting when the first open arrives, so the open runs
# in a detached subshell that first does a bounded wait for the supervisor's
# Chromium to hold the profile singleton. Launching earlier would make this
# process the singleton owner, and the supervisor would then park its own
# browser until this one exits.
set -u

if [ "$#" -lt 1 ]; then
echo "usage: vnc-open <url>" >&2
exit 2
fi

export DISPLAY="${ENCLAVE_VNC_DISPLAY:-:99}"
STATE_DIR=/tmp/enclave-vnc
PROFILE="$STATE_DIR/chromium"
LOG_DIR="$STATE_DIR/log"

# The supervisor normally creates the log dir, but an open can arrive before
# the stack is up, and a failed open must stay diagnosable.
mkdir -p "$LOG_DIR" || exit 1

# After the bounded wait the launch proceeds regardless: with no live
# singleton, becoming the instance is the best remaining way to show the URL.
# The launch goes through the shared vnc-chromium wrapper, so a fallback
# instance behaves identically to a supervisor-started one.
(
(
for _ in $(seq 1 100); do
[ -e "$PROFILE/SingletonSocket" ] && break
sleep 0.3
done
exec /usr/local/bin/vnc-chromium "$@"
) >> "$LOG_DIR/vnc-open.log" 2>&1 &
)
Loading
Loading