Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# The product, from the published images. `docker compose up -d`.
#
# On its own - downloaded into an empty directory, no clone - this file pulls
# `ghcr.io/vstorm-co/agenticos-backend` and `agenticos-frontend`, starts
# Postgres (pgvector), Redis, the background worker, the API and the
# console, runs the migrations, and answers on http://localhost:3000. Every
# variable carries a default, so there is no `.env` to write first; write one
# beside this file when there is something to change - `AGENTICOS_VERSION` to
# pin a release, `PUBLIC_*` when the browser reaches the host by a name other
# than `localhost`, a provider key. `docs/install.md` has the list.
#
# In a clone, Compose merges `docker-compose.override.yml` over this file on its
# own, and that override is what turns it into the local development stack:
# source built into the image, bind-mounted and reloading, the database and
# Redis published to the host. `make dev` is that pair of files; so is a bare
# `docker compose up` in a checkout. The other two environments have files of
# their own:
#
#   docker-compose.yml           this one - the product, from images
#   docker-compose.override.yml  merged in a clone - built from source, reloading
#   docker-compose-dev.yml       the dev server (published images, no bind mounts)
#   docker-compose-prod.yml      production
#
# The frontend used to be a separate file in every environment. It is a service
# here now, because the console is part of the product and a `docker compose up`
# that leaves it out answers on a port nobody was told about. Locally it sits
# behind the `console` profile (the override puts it there), so that `bun dev`
# on the host is not fighting a container for :3000.
#
# The application containers read two env files, both optional: `.env` beside
# this file - where a standalone install keeps its settings, and the same file
# Compose reads for `${...}` - and `backend/.env`, which is where a clone keeps
# them and where the rest of this repository says to look.

services:
  # Runs the migrations and exits. `app` and `worker` wait for it to have
  # *completed*, not for the database to be up, so a first `up` on an empty
  # volume is one command and a second `up` on a migrated one is a no-op that
  # takes a second. `docker compose logs migrate` is where a failed migration
  # says why the API never started.
  migrate:
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    command: agenticos db upgrade
    env_file:
      - path: ./.env
        required: false
      - path: ./backend/.env
        required: false
    environment:
      - ENVIRONMENT=${ENVIRONMENT:-local}
      # The same interpolation `db` reads, so the two agree by construction -
      # the application's own default password is empty, and the image carries
      # no env file to fill it.
      - POSTGRES_HOST=db
      - POSTGRES_USER=${POSTGRES_USER:-postgres}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
      - POSTGRES_DB=${POSTGRES_DB:-agenticos}
      - REDIS_HOST=redis
    networks:
      - backend
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  app:
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    # Longer than background.drain()'s 30s timeout, or Docker's 10s default
    # SIGKILLs the container mid-drain and an upload stays `processing` (#11).
    stop_grace_period: 40s
    ports:
      - "8000:8000"
    volumes:
      - media_data:/app/media
    env_file:
      - path: ./.env
        required: false
      - path: ./backend/.env
        required: false
    environment:
      - DEBUG=${DEBUG:-false}
      - ENVIRONMENT=${ENVIRONMENT:-local}
      - POSTGRES_HOST=db
      - POSTGRES_USER=${POSTGRES_USER:-postgres}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
      - POSTGRES_DB=${POSTGRES_DB:-agenticos}
      - REDIS_HOST=redis
      # The browser profile's egress proxy, on by default so starting the browser
      # starts the pinning path too. Set BROWSER_EGRESS_PROXY= (empty) in the root
      # .env only for a browser outside compose that cannot reach it.
      - BROWSER_EGRESS_PROXY=${BROWSER_EGRESS_PROXY-http://browser-egress:3128}
      # Empty outside compose, so a bare `uvicorn` run degrades to "no container
      # workspace available" rather than erroring. Note what is absent here:
      # no docker.sock, which is the whole point of the sandbox service below.
    # One worker by default. `--workers` above 1 puts uvicorn's supervisor at
    # PID 1, which replaces a worker that dies; at 1 the process *is* PID 1, so
    # a death is a container exit and `restart:` below is what replaces it. A
    # worker that is wedged rather than dead kills itself either way
    # (`app/core/watchdog.py`). Each worker imports the application on its own,
    # 460 MiB measured, so raise this with the host's memory in mind -
    # docker-compose-prod.yml sizes four.
    #
    # `--ws websockets-sansio`: uvicorn's `auto` picks the legacy websockets
    # implementation, which fails the handshake against websockets >=14, and the
    # chat is a WebSocket.
    command: >-
      uvicorn app.main:app --host 0.0.0.0 --port 8000
      --workers ${UVICORN_WORKERS:-1}
      --timeout-worker-healthcheck ${UVICORN_WORKER_HEALTHCHECK_TIMEOUT:-60}
      --ws websockets-sansio
    networks:
      - backend
      # Where `artifact-render` is, when its profile is up. Empty otherwise.
      - render
      - browser
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # This probe used to be a HEALTHCHECK in backend/Dockerfile, which every
    # service built from the image inherited and only the API could answer. It
    # lives with the service that serves it now; see `worker` for the other half.
    # Two things are not carried over verbatim. The status: `httpx.get` on its
    # own returns a response object whatever the status, so the old probe passed
    # on a 500 as happily as on a 200 - `urlopen` raises on anything >= 400. And
    # `start_period`, which was 5s - shorter than this application takes to
    # import on any machine measured, so every start went through a spell of
    # `unhealthy` before settling.
    #
    # The standard library, not httpx: a probe is a fresh interpreter every 30s,
    # and importing httpx doubled what each one cost (956 ms against 431 ms on
    # the production host, #2228).
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=5)"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    restart: unless-stopped

  db:
    # pgvector, not stock postgres: the RAG store runs
    # `CREATE EXTENSION IF NOT EXISTS vector` when a collection is first
    # written to, and on postgres:16-alpine that fails with
    # `extension "vector" is not available` — so every document upload 500s
    # before a row is committed. Same major version, same data directory.
    image: pgvector/pgvector:pg16
    environment:
      - POSTGRES_USER=${POSTGRES_USER:-postgres}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
      - POSTGRES_DB=${POSTGRES_DB:-agenticos}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    # `start_period` is what `depends_on: condition: service_healthy` is meant to
    # wait through, and this healthcheck had none. Two things go wrong without
    # it, and the second is the one that cost a deploy (#1831). A first start on
    # an empty volume runs `initdb` before Postgres answers, and every probe
    # until then counts toward `retries` rather than being the expected answer
    # for a database that is still starting. And a daemon that kept the previous
    # run's `unhealthy` across a restart answers `service_healthy` with it
    # immediately, before this run's first probe has had a chance to replace it,
    # so a container that was repaired fails the deploy that repaired it.
    # Thirty seconds covers `initdb` plus the extension; `scripts/deploy.sh`
    # covers the rest.
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

  redis:
    # Valkey, not Redis: Redis is RSALv2/SSPLv1 from 7.4 and neither is an
    # open-source licence, which is a component nobody should meet for the
    # first time in a security review. Valkey is the BSD-3-Clause fork of
    # Redis 7.2 and speaks the same protocol, so the service name, the port
    # and every `REDIS_*` setting stay as they are (docs/licenses.md).
    image: valkey/valkey:8-alpine
    # `--save ''` and no volume: rate-limit buckets, dedupe claims and
    # membership answers all carry a TTL and all rebuild themselves, so the
    # snapshot bought nothing - and cost production a crash-loop when Valkey 8
    # met the RDB redis:7-alpine had written (#1830).
    command: valkey-server --save ''
    networks:
      - backend
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
      # Ten seconds, for the reason `db` gives above. Valkey is ready in well
      # under one, so this is not about a slow start: it is the grace a
      # dependency is owed before its health status is taken as a verdict.
      start_period: 10s
    restart: unless-stopped

  # The background worker (#2219): fired triggers, ingestion, syncs, cleanups and
  # every scheduled sweep, claimed from a queue in Postgres. One long-lived
  # process imports the application once and serves every pool; ingestion runs
  # in child processes under a memory watchdog (`WORKER_INGEST_MEMORY_MB`) set
  # below this container's limit, so the watchdog - which says why - acts before
  # the kernel does. A second worker, here or on another host, shares the queue.
  worker:
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    volumes:
      - media_data:/app/media
    command: python -m app.worker
    env_file:
      - path: ./.env
        required: false
      - path: ./backend/.env
        required: false
    environment:
      - DEBUG=${DEBUG:-false}
      - ENVIRONMENT=${ENVIRONMENT:-local}
      - POSTGRES_HOST=db
      - POSTGRES_USER=${POSTGRES_USER:-postgres}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
      - POSTGRES_DB=${POSTGRES_DB:-agenticos}
      - REDIS_HOST=redis
      # The browser profile's egress proxy, on by default so starting the browser
      # starts the pinning path too. Set BROWSER_EGRESS_PROXY= (empty) in the root
      # .env only for a browser outside compose that cannot reach it.
      - BROWSER_EGRESS_PROXY=${BROWSER_EGRESS_PROXY-http://browser-egress:3128}
    networks:
      - backend
      - render
      - browser
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # The worker touches its health file only after a heartbeat that reached the
    # database, so a worker whose loop is stuck or whose database is gone reads
    # unhealthy: older than 90s is six missed 15s heartbeats. The standard library
    # only, like every probe here (#2228). The start period covers importing the
    # application and starting the ingest children, each of which imports it too.
    healthcheck:
      test: ["CMD", "python", "-c", "import os, sys, time; sys.exit(time.time() - os.stat(os.environ.get('WORKER_HEALTH_FILE', '/tmp/agenticos-worker-alive')).st_mtime > 90)"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 90s
    # Longer than the worker's own drain (30s), so a stop lets running jobs
    # finish or be given back with their attempt rather than being killed mid-write.
    stop_grace_period: 45s
    deploy:
      resources:
        limits:
          memory: ${WORKER_MEMORY_LIMIT:-4g}
    restart: unless-stopped

  # The console. `BACKEND_URL` is the server-side address its route handlers
  # proxy to; the `PUBLIC_*` values are what it tells the *browser* to call, and
  # they are read when the container starts rather than baked into the image
  # (#1544) - so the same image serves localhost and a host with a name, and a
  # change is a restart. They stay `localhost` until the site is reached by
  # another name; then set them beside `FRONTEND_URL` and `CORS_ORIGINS`, which
  # are the backend's side of the same fact.
  frontend:
    image: ghcr.io/vstorm-co/agenticos-frontend:${AGENTICOS_VERSION:-latest}
    environment:
      - NODE_ENV=production
      - BACKEND_URL=http://app:8000
      - PUBLIC_API_URL=${PUBLIC_API_URL:-http://localhost:8000}
      - PUBLIC_WS_URL=${PUBLIC_WS_URL:-ws://localhost:8000}
      - PUBLIC_SITE_URL=${PUBLIC_SITE_URL:-http://localhost:3000}
      # Where artifact pages are framed from, when not the API origin above.
      - ARTIFACT_ORIGIN=${ARTIFACT_ORIGIN:-}
      # The composer refuses a file above this before uploading it; the backend's
      # `CHAT_MAX_UPLOAD_SIZE_MB` is the limit it would hit otherwise, so the two
      # are the same name and should be given the same value.
      - CHAT_MAX_UPLOAD_SIZE_MB=${CHAT_MAX_UPLOAD_SIZE_MB:-10}
      - DIAGNOSTICS_ENABLED=${DIAGNOSTICS_ENABLED:-true}
      - DIAGNOSTICS_PUBLIC_URL=${DIAGNOSTICS_PUBLIC_URL:-https://analytics-agenticos.testapp.ovh}
      - OAUTH_PROVIDERS=${OAUTH_PROVIDERS:-google}
      - OIDC_DISPLAY_NAME=${OIDC_DISPLAY_NAME:-}
      - OIDC_ICON=${OIDC_ICON:-}
      - LDAP_DISPLAY_NAME=${LDAP_DISPLAY_NAME:-}
      - KERBEROS_DISPLAY_NAME=${KERBEROS_DISPLAY_NAME:-}
      # `instrumentation.ts` registers an OpenTelemetry SDK on every boot, and
      # `@vercel/otel` off Vercel takes its exporter from these two - so without
      # them the frontend's server-side spans are built and dropped in-process.
      # Passed through empty by default rather than omitted: an operator who
      # points them at Logfire (the same project the backend writes to) then gets
      # traces without editing a compose file. `frontend/README.md` has the pair.
      - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
      - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}
    ports:
      - "3000:3000"
    networks:
      - backend
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      # The oven/bun image has no curl/wget, so probe with bun's built-in fetch.
      test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
      interval: 30s
      timeout: 10s
      start_period: 40s
      retries: 3
    restart: unless-stopped

  # The only service that holds the Docker socket, and the reason an agent can
  # be given a container without this application ever having Docker access.
  # Behind a profile so a host that will not have the socket mounted turns it
  # off by editing one variable rather than deleting a service.
  #
  # The socket is an unauthenticated API for root on the host. Read-only does
  # not help: the API alone is enough. So the service is never published, and
  # its token is worth exactly as much as the socket is.
  minio:
    # The object store `FILE_STORAGE_BACKEND=s3` writes to, for developing
    # against that backend and for the integration tests that exercise it
    # (#1423). Behind a profile, because a deployment that keeps the default
    # local backend needs no object store at all: `docker compose --profile
    # objectstore up -d minio`, or `make docker-minio`.
    #
    # `pgsty/minio`, the community-maintained build of MinIO, because MinIO
    # stopped publishing images: `quay.io/minio/minio` and `minio/minio` both
    # answer 401 for every tag now, so a pin to either fails on every machine
    # with an error that reads like a login problem. Same binary, same `mc`
    # for the healthcheck below, and its web console is the full one. A dated
    # release rather than `latest`, so a version bump stays a decision.
    image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
    profiles: [objectstore]
    command: server /data --console-address ":9001"
    environment:
      - MINIO_ROOT_USER=${FILE_STORAGE_S3_ACCESS_KEY:-minioadmin}
      - MINIO_ROOT_PASSWORD=${FILE_STORAGE_S3_SECRET_KEY:-minioadmin}
    volumes:
      - minio_data:/data
    ports:
      - "9000:9000"
      - "9001:9001"
    networks:
      - backend
    healthcheck:
      # `mc ready` rather than curl: the image carries the client and carries no
      # curl, and the endpoint answers 403 to an unsigned request, which a bare
      # HTTP check reads as unhealthy.
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  artifact-render:
    # The browser `preview_artifact` draws a page in, so an agent can see what it
    # published. Opt-in: `docker compose --profile artifact-render up -d`, then in
    # `backend/.env` ARTIFACT_RENDER_CDP_URL=http://artifact-render:9222, with the
    # backend image built with the `browser` extra (`BACKEND_EXTRAS="--extra
    # browser"` locally). Never on BROWSER_CDP_ALLOWED_HOSTS: that list is what
    # agent authors may drive, and the browser tool's endpoint guard
    # (`browser_use._endpoint.is_renderer`) refuses this host anyway.
    #
    # On `render` alone, which has no route off the host. The API answers every
    # request a render makes, so the browser needs no network of its own, and a
    # page that broke out of Chromium finds nothing to talk to. Never the browser
    # a browsing agent drives: that one needs the internet, and this one renders
    # people's records. Chromium's own sandbox is off in this image (it needs
    # privileges a container is not given), so the container is the boundary:
    # read-only, no capabilities, bounded memory, CPU and processes.
    #
    # A version, not `latest`, so a Chromium upgrade is a decision.
    image: chromedp/headless-shell:155.0.8059.40
    profiles: [artifact-render]
    # Appended to the image's own flags.
    command:
      - --disable-dev-shm-usage
      - --no-first-run
      - --disable-background-networking
      - --disable-component-update
      - --disable-default-apps
      - --disable-sync
      - --mute-audio
      - --hide-scrollbars
    read_only: true
    tmpfs:
      - /tmp
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    mem_limit: 1g
    cpus: 1.0
    pids_limit: 256
    networks:
      - render
    restart: unless-stopped

  # The browser the web browser capability drives (#2127). Behind a profile,
  # because only an agent bound to `browser_use` needs one: `docker compose
  # --profile browser up -d browser`. Never published - the API reaches it on the
  # compose network as `http://browser:9222`, which `BROWSER_CDP_ALLOWED_HOSTS`
  # has to name. A full Chromium with a window under a virtual display
  # (`docker/browser`), so a site sees a desktop browser rather than a headless
  # shell it refuses on sight (#2130). `TZ` and `BROWSER_LANG` are where and in
  # which language it says it is.
  browser:
    image: ghcr.io/vstorm-co/agenticos-browser:${AGENTICOS_VERSION:-latest}
    profiles: [browser]
    environment:
      - TZ=${BROWSER_TZ:-UTC}
      - BROWSER_LANG=${BROWSER_LANG:-en-US}
    # Room for pages that use shared memory; the default 64 MB crashes tabs.
    shm_size: 1gb
    networks:
      - browser
    restart: unless-stopped

  # The browser's egress proxy (`docker/browser-egress`): the app and the worker
  # default BROWSER_EGRESS_PROXY to it, so every browse without an organization
  # proxy goes out through it. It resolves each host itself, refuses
  # private, loopback, link-local and metadata addresses, and connects to the
  # address it checked - so neither a redirect nor a name that rebinds between
  # two lookups takes a page inside the deployment's network.
  browser-egress:
    image: ubuntu/squid:6.6-24.04_edge
    profiles: [browser]
    volumes:
      - ./docker/browser-egress/squid.conf:/etc/squid/squid.conf:ro
    networks:
      - browser
    restart: unless-stopped

  sandboxd:
    # Exactly the `pydantic-ai-backend` version in backend/uv.lock: the backend's
    # client and this service ship together, and a client newer than the service
    # calls routes it does not have (#2484). Bump both at once;
    # backend/tests/test_sandboxd_compat.py fails when they differ.
    image: ghcr.io/vstorm-co/sandboxd:0.2.39
    profiles: [sandbox]
    # Without an init the server is PID 1, and Python reaps only the children it
    # waits on: anything orphaned under it stays a zombie for the container's
    # lifetime, and production once showed more than 600. docker-init reaps
    # them, as it already does in every sandbox this service starts.
    init: true
    # The token comes from the same files every other secret in this stack comes
    # from, rather than through compose interpolation: interpolation reads the
    # project root's `.env` only, so a token generated into `backend/.env` -
    # where `make sandbox-token` puts it - would have been invisible here. Every
    # other variable in this file carries a default precisely so the stack
    # starts with no env file at all. The service exits naming the variable when
    # it is missing, which `docker compose logs sandboxd` shows.
    env_file:
      - path: ./.env
        required: false
      - path: ./backend/.env
        required: false
    environment:
      SANDBOXD_HOST: 0.0.0.0
      SANDBOXD_PORT: "8080"
      # One host path, mounted at the same location on both sides, which is the
      # only arrangement that works: this service creates the directory and then
      # asks the *daemon* to bind-mount it, and the daemon resolves the path on
      # the host. A named volume, or any path existing only inside this
      # container, is refused with "mounts denied: the path is not shared from
      # the host".
      #
      # Without it the service keeps nothing on disk, and then a workspace can
      # only be read by starting a container - which browsing deliberately never
      # does, so the Files panel could show a conversation's files only as
      # "this host's files could not be read". `persist_containers` did keep them
      # alive; it did not make them readable.
      #
      # `/tmp` rather than the `/var/lib` of the server files: it is shared by
      # Docker Desktop by default, so this works on a laptop with no host setup,
      # and it is world-writable, so the service's uid 10001 needs no `install
      # -d -o`. The cost is that a reboot sweeps it - fine for local dev, wrong
      # for a host somebody's work lives on. See docker-compose-prod.yml.
      SANDBOXD_WORKSPACE_ROOT: ${SANDBOX_WORKSPACE_ROOT:-/tmp/agenticos-sandbox-workspaces}
      # Which environments an agent may ask for. **Generated - do not edit here.**
      # `make sandbox-runtimes` writes it from
      # backend/app/core/catalog/sandbox_runtimes.json, which is where a runtime
      # is added or a package changed, and which the connection dialog offers as
      # well - so the two cannot disagree about what this deployment ships.
      # `backend/tests/test_sandbox_runtime_catalog.py` fails when this line has
      # drifted from that file.
      #
      # Written into a tracked file rather than read from one at start-up because
      # `docker compose up` has to work without generating anything first;
      # otherwise a deployment silently gets the library's own default allowlist.
      #
      # Not a form in the product, and that is upstream's decision rather than an
      # omission: `PUT /policy` changes ceilings and lifetimes at run time and
      # deliberately refuses `runtimes` membership, because naming an image is a
      # decision about isolation and this token belongs to an application rather
      # than to whoever runs the host. Changing the list is a restart, and
      # `prewarm` rebuilds what it names as the service starts rather than inside
      # somebody's first request - which is minutes on a cold cache, and the
      # reason this deployment ships two runtimes rather than eight.
      SANDBOXD_RUNTIMES: '{"workbench":{"description":"Python, Node and LibreOffice - documents, data, charts and code","runtime":{"name":"workbench","base_image":"python:3.12-slim","description":"Python, Node and LibreOffice - documents, data, charts and code","setup_commands":["apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates ripgrep fd-find jq less procps xz-utils poppler-utils libreoffice-writer libreoffice-calc libreoffice-impress unzip zip tzdata fonts-dejavu-core && ln -s /usr/bin/fdfind /usr/local/bin/fd && rm -rf /var/lib/apt/lists/*","arch=\"$$(dpkg --print-architecture)\"; case \"$$arch\" in amd64) node_arch=x64 ;; arm64) node_arch=arm64 ;; *) echo \"no Node build for $$arch\" >&2; exit 1 ;; esac; curl -fsSL \"https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-$${node_arch}.tar.xz\" | tar -xJ -C /usr/local --strip-components=1 --exclude CHANGELOG.md --exclude README.md --exclude LICENSE && npm install -g tsx @anthropic-ai/claude-code@2.1.288 @openai/codex@0.160.0 && npm cache clean --force","curl -fsSL https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh"],"packages":["liteparse","pypdf","python-docx","openpyxl","python-pptx","pillow","pandas","duckdb","matplotlib","httpx","requests","beautifulsoup4","lxml","markdownify","pyyaml","tabulate","reportlab"],"env_vars":{"PYTHONUNBUFFERED":"1","PYTHONDONTWRITEBYTECODE":"1","PYTHONUTF8":"1","MPLBACKEND":"Agg","PIP_DISABLE_PIP_VERSION_CHECK":"1"}},"mem_limit":"2g","network_mode":"bridge"},"desktop":{"description":"A Linux desktop (XFCE and Firefox) for computer use, with Python and a shell","runtime":{"name":"desktop","base_image":"python:3.12-slim","description":"A Linux desktop (XFCE and Firefox) for computer use, with Python and a shell","setup_commands":["apt-get update && apt-get install -y --no-install-recommends xvfb x11-utils xfce4 xfce4-terminal dbus-x11 xdotool scrot firefox-esr fonts-dejavu-core fonts-noto-color-emoji git curl ca-certificates jq procps unzip && rm -rf /var/lib/apt/lists/*"],"packages":[],"env_vars":{"PYTHONUNBUFFERED":"1","PYTHONUTF8":"1","PIP_DISABLE_PIP_VERSION_CHECK":"1"}},"mem_limit":"2g","network_mode":"bridge"}}'
      # An escape starts from whoever the container runs as, and the default is
      # root. The image is built around this uid - a home directory and a
      # virtualenv it owns, without which an agent's `pip install` fails - so it
      # only applies to a runtime this deployment builds, which both of ours are.
      # It also makes every file an agent writes owned by this uid on the host,
      # rather than by root.
      #
      # **The service's own uid, and it has to be.** Opening a session `chown`s
      # the workspace to this user, and a sandboxd running unprivileged can only
      # do that for itself - so a different number here fails at the first
      # session rather than at start-up.
      SANDBOXD_SANDBOX_UID: "10001"
      SANDBOXD_PERSIST_CONTAINERS: "true"
      # What a session installed inside its container - the build, the wheels,
      # `node_modules` - reclaimed a day after it stopped. Without it a persisted
      # container is kept for ever and the host fills up with the ones nobody
      # reattached to. The workspace is untouched: the files are the work, the
      # container is rebuildable.
      SANDBOXD_CONTAINER_TTL: "86400"
      # One organization cannot occupy the pool of the whole installation
      # (`SANDBOXD_MAX_SESSIONS`, 20). Ten rather than five because a session is
      # per conversation by default: five was two people with three chats open,
      # and the sixth was refused with "already holds 5 of 5".
      SANDBOXD_MAX_SESSIONS_PER_TENANT: "10"
      SANDBOXD_NETWORK_MODE: none
      # Its own dashboard asks a human to paste a root-equivalent token into a
      # browser. AgenticOS proxies the same data behind its own authorization.
      SANDBOXD_UI_ENABLED: "0"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ${SANDBOX_WORKSPACE_ROOT:-/tmp/agenticos-sandbox-workspaces}:${SANDBOX_WORKSPACE_ROOT:-/tmp/agenticos-sandbox-workspaces}
    # Reaches the daemon by supplementary group rather than by running as root.
    # `stat -c '%g' /var/run/docker.sock` on the host; 0 on Docker Desktop,
    # where the socket is proxied and already reachable.
    group_add:
      - "${DOCKER_GID:-0}"
    networks:
      - backend
    restart: unless-stopped

networks:
  backend:
    # No fixed name: every service that joins this network is in this file, so
    # compose scopes it to the project, and two stacks on one host do not share
    # a bridge on which `db` and `app` resolve to the neighbour's containers.
    driver: bridge
  render:
    # The page renderer's network: the API and the browser, and no way out.
    internal: true
  browser:
    # The browser's own network, which only the API and the worker share with it
    # to reach `browser:9222`. A page it is sent to - or redirected to - cannot
    # reach the database, Redis or MinIO, because nothing else is on it.
    driver: bridge

volumes:
  media_data:
  minio_data:
  postgres_data: