A full Linux desktop in a container, streamed over WebRTC, shared by people and AI agents.
2.2K
A collaborative Linux desktop for people and AI agents.
A complete Linux desktop that runs inside a container with no physical monitor, streams to the browser over WebRTC, and is driven at the same time by people and by an AI agent that sees and acts on the same X display.
One docker compose up gives a full desktop that people and an agent share β no
control plane, no database, nothing else to stand up.
| Image | Container | What it is |
|---|---|---|
cnsoluciones/sentineldesk | sentineldesk | The desktop: X, the browser, the apps, the WebRTC stream, the MCP server |
cnsoluciones/sentineldesk-agent | sentineldesk-agent | The runtime that drives it β the brain behind the chat panel |
The agent is optional, and it is a separate image on purpose. It has no display, no browser and no home of the desktop's: everything it does, it does through MCP over a Unix socket, so the container boundary and the protocol say the same thing. Naming the desktop is how you leave it out:
docker compose up -d # the desktop and an agent beside it
docker compose up -d sentineldesk # the desktop, on its own
The desktop has no depends_on and no link to the agent, so the second line is
a complete deployment rather than a crippled one. Order does not matter either:
started first, the agent waits for the desktop and reconnects when it comes back.
How the two are joined β one volume, and it is the whole security story:
sentineldesk-run ββ a directory of Unix sockets, mounted in both
That is the only link. The agent has no published ports and no network of its own, so nothing about the conversation, or about what the agent does, leaves the host.
No clone and no build β download the compose file and bring it up:
curl -fsSLO https://raw.githubusercontent.com/sentineldesk/desktop/main/docker-compose.yml
AUTH_PASS=change-me HOST_IP=<your-ip> docker compose up -d
# β open https://<your-ip>:8080 and log in
HOST_IP is the address browsers reach this host at β its LAN address, or the
public IP on a VPS. Off localhost it is required rather than optional: WebRTC
would otherwise advertise the container's bridge address, which nothing outside
can reach, and the video connects and stays black.
Or let the installer do it β it installs Docker (and socat) if missing, writes
the compose file to /opt/sentineldesk and starts both services:
curl -fsSL https://raw.githubusercontent.com/sentineldesk/desktop/main/install.sh | sudo bash
| Installer flag | |
|---|---|
--full | the full image (LibreOffice, Firefox, GIMPβ¦); default is lite |
--pass <p> / --user <u> | the login credentials (default user admin, password generated) |
--ip <addr> | the address browsers reach this host at β set it on a public VPS |
--port <n> | the web port (default 8080) |
--vpn | allow the built-in OpenVPN client (adds NET_ADMIN + /dev/net/tun) |
--no-agent | the desktop only; no agent container |
--dir <path> | where to write docker-compose.yml (default /opt/sentineldesk) |
--no-pull | use the image already on the host |
The published file has more comments than configuration; this is its shape:
name: sentineldesk
services:
sentineldesk:
image: cnsoluciones/sentineldesk:${SENTINELDESK_TAG:-latest} # :full for the heavier apps
container_name: sentineldesk
restart: unless-stopped
ports:
- "${HTTP_PORT:-8080}:8080"
- "3478:3478/udp" # the embedded STUN responder
- "59000-59049:59000-59049/udp" # WebRTC media
environment:
- AUTH_USER=${AUTH_USER:-admin}
- AUTH_PASS=${AUTH_PASS:-change-me}
- WEBRTC_MIN_PORT=59000
- WEBRTC_MAX_PORT=59049
- NAT1TO1_IP=${HOST_IP:-127.0.0.1}
- TLS_SELFSIGNED=1
- TLS_HOSTS=${HOST_IP:-127.0.0.1}
- MCP_SOCK=/run/sentineldesk/mcp.sock
- TZ=${TZ:-UTC}
- KEYBOARD_LAYOUT=${KEYBOARD_LAYOUT:-us}
- KEYBOARD_VARIANT=${KEYBOARD_VARIANT:-}
volumes:
- sentineldesk-home:/home/sentineldesk
- sentineldesk-run:/run/sentineldesk
- sentineldesk-audit:/var/log/sentineldesk
- sentineldesk-work:/tmp/sentineldesk
shm_size: "2gb"
# The built-in OpenVPN client needs these. Uncomment only if you will use it.
# cap_add: [NET_ADMIN]
# devices: ["/dev/net/tun"]
agent:
image: cnsoluciones/sentineldesk-agent:${SENTINELDESK_AGENT_TAG:-latest}
container_name: sentineldesk-agent
restart: unless-stopped
environment:
- MCP_SOCK=/run/sentineldesk/mcp.sock
volumes:
- sentineldesk-run:/run/sentineldesk # the sockets β the only link
- sentineldesk-agent:/home/agent # model choice, keys, history
volumes:
sentineldesk-home: { name: sentineldesk-home }
sentineldesk-run: { name: sentineldesk-run }
sentineldesk-agent: { name: sentineldesk-agent }
sentineldesk-audit: { name: sentineldesk-audit }
sentineldesk-work: { name: sentineldesk-work }
The volume names are pinned rather than prefixed with the project name: an
agent on the host looks for a volume literally called sentineldesk-run to find
the socket, and anybody who started the desktop with docker run once comes back
to the same home rather than an empty one. On a machine where those volumes
already exist, Compose prints a cosmetic volume β¦ was not created by Docker Compose warning β that is it adopting them, which is what it should do here.
.envCompose reads a .env beside the file, which is the tidier place for anything
you would otherwise repeat on the command line:
cat > .env <<'EOF'
AUTH_USER=admin
AUTH_PASS=a-real-password
HOST_IP=192.168.0.100
SENTINELDESK_TAG=latest # or `full` for the heavier apps
SENTINELDESK_AGENT_TAG=latest
TZ=America/Argentina/Buenos_Aires
KEYBOARD_LAYOUT=latam
HTTP_PORT=8080
EOF
docker compose up -d
Every one of those has a default; only AUTH_PASS and HOST_IP really want
setting before anybody else can reach the deployment.
docker compose ps # what is up
docker compose logs -f sentineldesk # follow the desktop
docker compose logs -f agent # follow the agent
docker compose restart agent # restart one service
docker compose down # stop both, keep the volumes
docker compose pull && docker compose up -d # upgrade in place
docker compose down leaves the volumes alone, so the home, the audit log and
the agent's keys survive it. To start genuinely fresh, remove them by name β and
read that as the destructive command it is:
docker volume rm sentineldesk-home sentineldesk-agent.
docker runCompose is the supported path, but a single container still works:
docker run -d --name sentineldesk \
-p 8080:8080 \
-p 3478:3478/udp \
-p 59000-59049:59000-59049/udp \
-e AUTH_USER=admin -e AUTH_PASS=change-me \
-v sentineldesk-home:/home/sentineldesk \
-v sentineldesk-run:/run/sentineldesk \
-e MCP_SOCK=/run/sentineldesk/mcp.sock \
--shm-size=2g \
cnsoluciones/sentineldesk:latest
Open http://localhost:8080β and log in.
Both images follow the same scheme; the desktop's tag also chooses how much desktop you get.
| Tag | What you get |
|---|---|
latest, lite | The everyday desktop: Chromium, VLC, terminals, file manager, remote-desktop clients |
full | Everything in lite plus LibreOffice, Firefox, GIMP, Wireshark and more |
<version>, <version>-lite, <version>-full | Pinned builds |
Package lists: docs/packages.mdβ .
cnsoluciones/sentineldesk-agent has no lite/full split β latest or a pinned
version. The two images release on their own cycles and are not required to
match: that is why they are two repositories.
Both are built for linux/amd64 and linux/arm64.
| Port | Purpose |
|---|---|
8080/tcp | Web interface (HTTP or HTTPS) |
3478/udp | Embedded STUN responder |
59000-59049/udp | WebRTC media range β must match WEBRTC_MIN_PORT / WEBRTC_MAX_PORT |
The agent publishes no ports at all.
| Volume | Mounted in | Purpose |
|---|---|---|
sentineldesk-home β /home/sentineldesk | desktop | Browser profiles, files, TLS certificate β kept across restarts |
sentineldesk-run β /run/sentineldesk | desktop and agent | The Unix sockets: MCP, and the chat wire the panel uses |
sentineldesk-audit β /var/log/sentineldesk | desktop | The action log, kept apart so wiping a browser profile never takes the record with it |
sentineldesk-work β /tmp/sentineldesk | desktop | Scratch space for screenshots and recordings in flight |
sentineldesk-agent β /home/agent | agent | The agent's model choice, keys and history β and any vendor CLI installed beside them |
--shm-size=2g (shm_size: "2gb") matters: Chromium crashes on the default 64 MB.
sentineldesk-agent covers the agent's whole home rather than one dotfile,
because the runtime can drive a model through a vendor's own CLI (claude,
codex, opencode) and those install into ~/.local/bin with credentials in
their own dotfiles. Without it, replacing the container keeps the preference
naming a tool and loses the tool.
| Variable | Default | What it does |
|---|---|---|
AUTH_USER | (empty) | Login username. Empty means no login β local use only. The compose file and the installer set admin |
AUTH_PASS | (empty) | Login password |
AUTH_SECRET | (generated) | Signing key for session tokens. Set it to keep sessions valid across restarts |
AUTH_TTL_HOURS | 12 | How long a session token lives |
ROOT_PASSWORD | (reuses AUTH_PASS) | Password for root inside the desktop |
| Variable | Default | What it does |
|---|---|---|
HTTP_PORT | 8080 | Port the web interface listens on |
HTTP_ADDR | (all interfaces) | Bind address |
PUBLIC_URL | (empty) | External URL the desktop is reached at, when behind a proxy |
NAT1TO1_IP | (empty) | The host's reachable IP, so ICE advertises an address a remote browser can connect to. Set it on a VPS |
WEBRTC_MIN_PORT / WEBRTC_MAX_PORT | (any) | UDP media range β must match the published ports |
STUN_EMBEDDED | true | Answer STUN from the container itself |
STUN_PORT | 3478 | Port for that responder |
STUN_SERVER | Google STUN | STUN server the server side uses |
CLIENT_STUN | Google STUN | STUN server handed to the browser |
CLIENT_TURN_URLS | (empty) | Comma-separated TURN URLs for browsers behind strict NAT |
TURN_USER / TURN_PASS | (empty) | TURN credentials |
ALLOWED_ORIGINS | (empty) | Restrict which origins may open the WebSocket |
| Variable | Default | What it does |
|---|---|---|
TLS_SELFSIGNED | true | Serve HTTPS with a self-signed certificate (WebRTC needs a secure context off localhost) |
TLS_HOSTS | (empty) | Hostnames/IPs the certificate is issued for |
TLS_DIR | /home/sentineldesk/.tls | Where the certificate persists |
TLS_CERT / TLS_KEY | (empty) | Use your own certificate instead |
Terminating TLS yourself with nginx, Caddy or Nginx Proxy Manager in front? Set
TLS_SELFSIGNED=0 so the backend speaks plain HTTP to the proxy.
| Variable | Default | What it does |
|---|---|---|
DISPLAY_WIDTH | 1920 | Screen width |
DISPLAY_HEIGHT | 1080 | Screen height |
DISPLAY | :0 | X display |
FPS | 30 | Capture frame rate |
ENCODER | auto | auto, nvenc, vaapi, x264, vp8 |
VIDEO_BITRATE_KBPS | 4000 | Target video bitrate |
MIN_VIDEO_BITRATE_KBPS | 1200 | Floor when the link degrades |
AUDIO_BITRATE | 96000 | Opus bitrate |
AUDIO_DEVICE | sentineldesk.monitor | PulseAudio source captured |
REMOTE_CURSOR | false | Draw the cursor into the video instead of overlaying it |
SOFTWARE_SCALE_HEIGHT | 0 | Downscale before encoding (0 = off) |
USE_DAMAGE | 1 | Encode only changed regions |
MAX_VIEWERS | 4 | How many browsers may watch at once |
RECORD_THREADS | 2 | Encoder threads for recording |
| Variable | Default | What it does |
|---|---|---|
AGENT_ENABLED | true | Offer the chat plane at all. 0 turns the panel off; the desktop is unaffected |
AGENT_SOCK | (beside MCP_SOCK) | The socket the agent runtime connects to. Leave it alone β relocating MCP_SOCK relocates both, which is the point |
AGENT_NAME | AI agent | How the agent is named in the room |
| Variable | Default | What it does |
|---|---|---|
MCP_SOCK | /run/user/1000/sentineldesk-mcp.sock | Where the MCP socket is created. Point it at a mounted volume β the compose file uses /run/sentineldesk/mcp.sock β so the agent container, or a host-side AI host, can reach it |
MCP_POLICY | full | What the agent may do: full, approve, safe, readonly |
MCP_DENY | (empty) | Additionally deny these tools (ssh_* matches by prefix) |
MCP_ALLOW | (empty) | When set, allow only these |
MCP_DISCOVERY | 0 | 1 advertises a core set of twelve tools; the rest stay callable by name |
| Variable | Default | What it does |
|---|---|---|
FILES_ROOT | /home/sentineldesk | Root of the browser file manager. / opens the whole container |
ACTION_LOG | /var/log/sentineldesk/actions.jsonl | Audit log of every MCP call |
ACTION_LOG_MAX_MB | 64 | Rotation size |
ACTIVITY_RETENTION_HOURS | 24 | How long activity history is kept |
SECRETS_FILE | /var/lib/sentineldesk/secrets | Encrypted store for saved credentials |
TZ | America/Argentina/Buenos_Aires (compose sets UTC) | The desktop's clock. Any tzdata name |
KEYBOARD_LAYOUT / KEYBOARD_VARIANT | us / (empty) | The X keyboard the desktop comes up with β e.g. latam, or es with nodeadkeys |
LANG / LC_ALL | C.UTF-8 | Locale β without UTF-8 a terminal mangles accented characters |
TZ and the keyboard are both things somebody notices in the first minute and
cannot fix from the browser, which is why they are in the compose file.
The agent container is deliberately almost unconfigured. It needs one thing:
| Variable | Default | What it does |
|---|---|---|
MCP_SOCK | (none) | The desktop's socket, reached through the shared sentineldesk-run volume: /run/sentineldesk/mcp.sock |
There is no API key in the environment, and that is deliberate β a provider key is not something an environment variable should carry into a container image or a shell history. The agent comes up with no model and says so; you add one from inside:
docker exec -it sentineldesk-agent sentineldesk-agent
# then: /connect
The key is checked before it is saved and stored in the sentineldesk-agent
volume. The chat panel in the browser goes from amber to green on its own β
nothing to reload.
That same session is the agent's full face β the browser panel is the short one:
/help | what you can type here |
/model | pick the model that answers next β switches mid-session, no restart |
/connect | add a provider's API key, checked before it is saved |
/panel (ctrl+b) | the session's context, tokens and cost so far |
/compact | fold the older conversation into a summary |
/rewind (/undo) | drop back before an earlier task β the desktop is not rewound |
/memory | have something remembered, permanently |
/stop (ctrl-c) | end the run after this turn |
/language | the language of the interface |
/exit (ctrl-d) | leave the session |
Useful flags on the same binary:
docker exec -it sentineldesk-agent sentineldesk-agent -mode ask # approve every change
docker exec -it sentineldesk-agent sentineldesk-agent -doctor # 15 checks, no model, no spend
docker exec -it sentineldesk-agent sentineldesk-agent -history # every past run: turns, calls, cost
docker exec -it sentineldesk-agent sentineldesk-agent \
-run "install nginx and show me it working" -max-turns 40 -max-spend 0.50 -max-time 10m
-mode ask stops before every call that changes something and lets reads
through, because a confirmation for every screenshot trains somebody to press
y without reading. -doctor is the first thing to run when something is
wrong: if it fails, the problem is not the key.
The runtime finds the socket in the sentineldesk-run volume by itself:
docker compose up -d sentineldesk # the desktop only
sentineldesk-agent -serve # the agent, on the host
That is the lightest way to work while changing the agent. Do not run both: two runtimes on one desktop means the newer connection wins and the older is dropped. Nothing breaks; it is just confusing. Pick one.
One variable does it β compose wires NAT1TO1_IP, TLS_HOSTS and the UDP range
together from it:
HOST_IP=203.0.113.10 AUTH_PASS=change-me docker compose up -d
# β open https://203.0.113.10:8080 and accept the self-signed certificate once
NAT1TO1_IP β the address ICE advertises. Without it the page stalls at
βEstablishing WebRTCβ, because the container advertised its bridge address.TLS_HOSTS with TLS_SELFSIGNED=1 β HTTPS for that host, the certificate
kept in the home volume so it is generated once. This is more than the lock
icon: browsers only allow the microphone and the rich clipboard on a secure
origin, so a desktop reached over plain HTTP is quietly missing features.WEBRTC_MIN_PORT / WEBRTC_MAX_PORT β pinned to the published UDP range,
which is what makes the media connect without host networking.A relay for hostile networks. Peer-to-peer fails on symmetric NAT and where
UDP is dropped. A coturn service is in the compose file, behind a profile and
off by default, because a relay carries every frame through it:
docker compose --profile turn up -d
It takes 3478/tcp (the desktop's embedded responder keeps 3478/udp) and
49160-49200/udp, with TURN_USER / TURN_PASS for its credentials. Set
CLIENT_TURN_URLS on the desktop when it is running, or browsers are never told
the relay exists.
Built-in VPN (optional). The desktop can dial an OpenVPN connection, which
needs a tunnel device and the capability to configure routes: uncomment
cap_add: NET_ADMIN and devices: /dev/net/tun on the sentineldesk service.
Leave them off on a deployment that will never dial one.
The agent container is one way to use the MCP plane; your own AI host is
another, and they are the same socket. With MCP_SOCK on a mounted volume, any
MCP host on the machine reaches it with a plain stdio relay β no docker exec,
no Docker socket:
{
"mcpServers": {
"sentineldesk": {
"command": "sudo",
"args": ["socat", "STDIO",
"UNIX-CONNECT:/var/lib/docker/volumes/sentineldesk-run/_data/mcp.sock"]
}
}
}
The agent is a participant, not an API bolted to the side. It shares the screen people are looking at: control is claimed and never assumed, every command runs in a terminal window the room can watch, and its only reach is that local Unix socket β 137 MCP tools covering the screen, the accessibility tree, the browser, windows, processes, SSH, recording and streaming, each classified by risk and by whether it requires control.
Full setup, the four permission levels and the tool catalogue: docs/mcp.mdβ .
The WebSocket login is the authentication gate; no HTTP endpoint returns
secrets. Both Unix sockets β MCP and the chat wire β are local and 0600, and
the agent container reaches the desktop through nothing else: no ports, no
network of its own, no shared display or home. Inside the desktop container the
desktop user has passwordless sudo β the container is the sandbox, so tighten
with Docker's own limits and do not mount sensitive host paths. Every MCP call
lands in the audit log in sentineldesk-audit, which is a separate volume so
that wiping a browser profile never takes the record with it.
Apache 2.0. The name and logo are trademarks of Federico Pereira and are not covered by that license β see the trademark policyβ .
Content type
Image
Digest
sha256:3bc32b15fβ¦
Size
914.9 MB
Last updated
21 days ago
docker pull cnsoluciones/sentineldesk