This file documents the Container manager subsystem (devplacepy/services/containers/, plus its routers/projects/containers and routers/admin/containers.py HTTP surface, and the shared ppy Docker image). Claude Code loads it automatically whenever a file under devplacepy/services/containers/ is read or edited.

Overview

Admin-only. Supervises container instances, all running one shared prebuilt image. Driven through the docker CLI via asyncio.create_subprocess_exec behind a pluggable Backend ABC, never a docker SDK. Reference: admin docs Services -> Container Manager and the containers API group.

Backend seam

backend/base.py defines the Backend ABC. DockerCliBackend drives docker via asyncio.create_subprocess_exec, streaming run logs line-by-line (_stream). FakeBackend is the in-memory test double (its image_exists returns True). runtime.get_backend()/set_backend(b) selects the active backend - tests call set_backend(FakeBackend()). A future Kubernetes/remote backend implements the same ABC. The container OS workspace mount point is the single constant backend/base.py WORKSPACE_MOUNT (/app).

Security (load-bearing)

Mounting the docker socket is root-equivalent on the host. Every run/exec/lifecycle/schedule operation is gated require_admin (HTTP) and requires_admin=True (Devii). --privileged is never passed. User input is never shell-interpolated - every call is an arg-list subprocess, never shell=True. Resource limits are applied via --cpus/--memory.

On top of the admin gate, per-user container isolation applies (see root CLAUDE.md "Project visibility (is_private) and read-only" convention): only the primary administrator (earliest-created Admin) and the instance owner (creator or project owner) may manage an instance; other admins get read-only access, and only when the instance's project is public. Enforced via content.py predicates owns_instance, can_view_project_containers, can_view_instance, can_manage_instance (all reusing is_primary_admin): the primary administrator sees and manages every container including those on private projects; any other admin can VIEW containers of others only when the instance's project is public (private-project containers of others are invisible; of private containers they see only their own), and can MANAGE (edit/lifecycle/exec/terminal/sync/delete/schedules) only instances they own - non-owners get 403 plus an audit row with result="denied" (WS close 1008). This is enforced at every entrypoint: routers/projects/containers/ (project_for + manage_guard + the exec WS), the admin Containers manager (_decorate filtering + per-row can_manage, _viewable_instance_or_404, _manage_denied, create + project-search), the Devii container tools (ContainerController._project + _require_manage), and the live view relay (broadcast topics never carry private-project instances).

One shared image, no in-app builds (load-bearing)

Every instance runs config.CONTAINER_IMAGE (default ppy:latest, override DEVPLACE_CONTAINER_IMAGE), built ONCE from ppy.Dockerfile via make ppy (context devplacepy/services/containers/files). There are no per-project Dockerfiles, builds, ContainerBuildService, or build UI - all removed.

service._launch calls api.run_spec_for(inst, config.CONTAINER_IMAGE). api.create_instance(project, *, name, ...) fails fast with ContainerError if backend.image_exists(CONTAINER_IMAGE) is false ("run 'make ppy'"); it no longer takes a dockerfile/build. backend.image_exists(ref) (docker image inspect) is part of the Backend ABC.

Migrating off the old per-project-build model: devplace containers prune-builds removes the legacy per-project images (backend.remove_image) and clears the dockerfiles/dockerfile_versions/builds tables (one-time); existing instances auto-repoint to ppy (their stored build_uid is ignored).

Default idle: run_spec_for runs ["sleep", "infinity"] when an instance has no boot_command, so a bare instance stays up instead of exiting (the image CMD is the same, but the explicit command makes it independent of the image).

Data model

store.py, indexed in init_db:

  • instances
  • instance_events (audit trail; also home of the status-history rows written by _set_status)
  • instance_metrics (ring buffer, METRICS_RING=720; sweep keeps it bounded)
  • instance_schedules (cron/interval/once)

Reconciler (ContainerService, service.py)

A BaseService reconciler; the model is desired-vs-actual, NOT a task per container. Each tick:

  1. backend.ps(label=devplace.instance) snapshots docker ps (label devplace.instance=<uid> is the join key).
  2. Converge each instance to its desired_state.
  3. Apply restart policies.
  4. Reap orphan containers (labeled but no DB row -> no orphans, no lost state).
  5. Fire due instance_schedules (reusing devii/tasks/schedule.py cron_next/next_run).
  6. Sample docker stats.

Only the service lock owner reconciles; HTTP handlers only flip desired_state / write events. docker run --name <slug> collision is the double-launch guard.

Workspace materialization

Workspace = /app (the WORKSPACE_MOUNT constant). project_files.export_to_dir materializes the project to config.CONTAINER_WORKSPACES_DIR/<project> (DATA_DIR/container_workspaces, default data/) once, bind-mounted RW; project_files.import_from_dir (the inverse) syncs it back on the sync action. Both run via asyncio.to_thread.

Runtime data must live OUTSIDE the devplacepy/ package and NOT under /static: workspaces (and zips) live in config.DATA_DIR (configurable via DEVPLACE_DATA_DIR). Never put generated data under STATIC_DIR - it is served publicly AND watched by dev --reload (scoped to --reload-dir devplacepy). The docker daemon must be able to bind-mount DATA_DIR for /app.

Every exec passes -w /app explicitly (DockerCliBackend.exec and the PTY exec in routers/projects/containers/instances.py), so one-shot container_exec, the interactive shell, and tmux sessions all start in the project workspace. The agent is told this (system prompt + container_exec tool summary) so it never prefixes a command with cd /app.

Shared operations

api.py holds the operations shared by routers/projects/containers/instances.py (and the rest of the routers/projects/containers/ subpackage) and the Devii ContainerController (services/devii/container/, handler="container", 7 instance tools). The reconciler service defaults disabled (needs docker); enable it on /admin/services.

HTTP routing surface

  • /projects/{slug}/containers - the routers/projects/containers/ subpackage: instances.py (creation/lifecycle/exec/logs/metrics/sync plus the exec websocket), schedules.py (cron/interval/once schedules), shared helpers in _shared.py. Every instance runs the shared ppy image. Discoverable from the project detail page's admin-only Containers button (gated by content.can_view_project_containers via the viewer_can_containers context flag) and from the admin index.
  • /admin/containers - routers/admin/containers.py: lists every instance across all projects with inline actions (start/stop/restart/terminal/edit/delete) plus a create modal (project search-select, run-as user search-select, boot language + source editor, restart policy, start-on-boot, env/ports/limits/ingress); /admin/containers/{uid} is the per-instance detail page (lifecycle, live logs/metrics, PTY terminal, schedules, ingress, sync, status history) and /admin/containers/{uid}/edit edits run-as user, boot language/script/command, restart policy, start-on-boot, and limits.

Key reuse rule: the admin Containers section adds NO lifecycle/logs/exec endpoints of its own - the instance carries its project_uid, so the admin detail route resolves the project and its frontend targets the existing /projects/{slug}/containers/instances/{uid}/... routes. Add any new instance operation to routers/projects/containers/instances.py only; the admin detail page picks it up for free.

UI - two surfaces over one API set

Both are discoverable (an earlier version of the per-project page had zero links to it - fixed).

  1. Per-project manager: templates/containers.html + static/js/ContainerManager.js handles instance creation through an app modal form (the _macros.html modal() macro + ModalManager .visible toggle); its instance list links out to the shared detail page. Reached from the project detail page's Containers button, and passes breadcrumbs so content clears the fixed nav.
  2. Admin Containers section: routers/admin/containers.py (mounted /admin/containers, sidebar link in admin_base.html, admin_section="containers"). GET /admin/containers lists every instance via store.all_instances() (decorated with project title/slug from one projects lookup) in an .admin-table. GET /admin/containers/data is the poll JSON. GET /admin/containers/{uid} renders templates/containers_instance.html + static/js/ContainerInstance.js - a dedicated detail page (lifecycle, poll logs/metrics, schedules add/delete, ingress, sync, interactive exec over a PTY WebSocket gated on the lock owner).

All container CSS (static/css/containers.css) uses app design tokens (--bg-card, --text-primary, --success/--danger/--warning, --radius) and the shared .card recipe.

Admin CRUD manager (/admin/containers)

/admin/containers is a full manager, not a read-only list. routers/admin/containers.py adds (all require_admin, all reuse api.* directly - no docker/exec backend duplicated):

  • POST /create (ContainerAdminCreateForm -> api.create_instance, project search-select + run-as user + boot fields + restart policy + start_on_boot + env/ports/limits/ingress).
  • GET/POST /{uid}/edit (ContainerEditForm -> api.update_instance_config, edits run_as_uid/boot_language/boot_script/boot_command/restart_policy/start_on_boot/cpu_limit/mem_limit).
  • Stacked literal lifecycle routes POST /{uid}/{start,stop,restart,pause,resume} (the no-wildcard rule below).
  • POST /{uid}/sync (bidirectional).
  • POST /{uid}/delete (soft, owner-or-admin via the admin guard).
  • Two JSON search endpoints GET /{projects,users}/search (back the create/edit search-selects via db.query LIKE).

Route order: /{uid}/edit and the literal verb routes are declared BEFORE the /{uid} catch-all.

Frontend: static/js/ContainerList.js (poll-render rows with inline action buttons + the create modal with two debounced search-select widgets and a boot-language toggle) and static/js/ContainerEdit.js (the edit page). Both route through Http.send/Http.getJson and surface errors via app.toast; the Terminal button reuses app.containerTerminals.open(slug, uid, name). The instance detail page (containers_instance.html) has a config summary (boot language, run-as uid, start-on-boot) plus a Status history card rendering the server-side instance_events.

Devii: container_create_instance takes the boot/run-as/start_on_boot args; container_configure_instance (controller._configure_instance -> api.update_instance_config) edits the same fields; both admin-only, audited via _DEVII_CONTAINER_EVENTS.

No wildcard verb route (load-bearing)

routers/projects/containers/instances.py registers every instance verb as a literal route - start/stop/pause/resume/restart (stacked @router.post decorators on one instance_action handler that reads the verb from request.url.path), plus delete/exec/sync/schedules. There is deliberately NO POST /instances/{uid}/{action} catch-all: a wildcard segment matches its literal siblings too (Starlette matches first-declared), so a catch-all would silently swallow exec/sync/delete/schedules as {"error": "unknown action: exec"} whenever it sat above them. Add any new instance verb as a literal route (and to the instance_action decorator stack if it just flips desired state) - never reintroduce a {action} wildcard.

Host port allocation

Host ports are auto-allocated and globally unique. parse_ports accepts a bare container port (8899, host side 0 = auto) or a pinned host:container. api.assign_host_ports (called in create_instance) resolves every host=0 to the lowest free port in [HOST_PORT_MIN=20001, 65535] that is neither in used_host_ports() (the union of every instance's published host ports from ports_json) nor currently bound on the host (_host_port_free test-binds 0.0.0.0:port). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port - previously a silent docker run "port is already allocated" failure. The resolved host port is what gets stored in ports_json and what the ingress proxy reads.

Floating terminals (interactive shell)

An instance's shell is NOT inline - it is a floating <container-terminal> (static/js/components/ContainerTerminal.js) over the exec WS (/projects/{slug}/containers/instances/{uid}/exec/ws, pty.openpty() + docker exec -it, admin + service_manager.owns_lock() gated). xterm.js + the fit addon are vendored under static/vendor/xterm/ and lazy-loaded. Opened by the instance-page Open terminal button (app.containerTerminals.open(slug, uid, name), ContainerTerminalManager) or by telling Devii to "attach <container>" - the admin-only open_terminal client action (client_actions.py, requires_admin=True) -> DeviiClient._openTerminal -> the same manager.

FloatingWindow base. It extends the generic FloatingWindow base (static/js/components/FloatingWindow.js + static/css/floating-window.css), which owns the Devii-style chrome: drag, native resize:both, maximize/fullscreen, geometry persistence. app.windows (WindowManager, components/WindowManager.js) assigns z-index on pointerdown so the last-clicked window is on top (below the avatar/toasts).

The Devii terminal also extends FloatingWindow. devii/devii-terminal.js reuses the base drag/geometry/preset/resize/persist plus _window/_registerWindow (z-order + context-menu) machinery, overriding only the parts that differ - _setState (Devii adds a closed state + launcher FAB), _defaultGeometry (bottom-right, 760x600), _changeFont (per-user --devii-font-size CSS var), _contextItems, and _persistGeometry (delegates to its richer {state,geometry,focused} blob under devii-terminal-state). Devii keeps native resize:both (no .fw-resize grip); devii.css is unchanged - the only base change required was a get _dragClass() getter (default fw-dragging) that Devii overrides to devii-dragging, so the base drag handlers toggle Devii's own class and its existing CSS drives everything.

Resize protocol. The exec WS treats a brace-leading JSON frame {"type":"resize","cols","rows"} as a control message: it TIOCSWINSZ-es the host pty (fcntl.ioctl) and signals the docker exec client (proc.send_signal(SIGWINCH)) so the new size forwards through to the container's exec tty (vim/top reflow). The SIGWINCH is required: with start_new_session=True the host slave is not the client's controlling terminal, so the kernel does not auto-deliver it; the pair shares its winsize, so the client reads the new size off its slave stdio and calls Docker's exec-resize API. Any non-resize frame is raw stdin (backward compatible). The fit addon drives it client-side, and the window has a dedicated bottom-right resize grip (.fw-resize, since xterm covers the native CSS resize corner - the base FloatingWindow adds a manual grip instead of relying on resize:both). xterm renders ANSI natively, so the old cleanTerm stripping is gone for the interactive shell (the one-shot exec box still strips output, since docker exec without -t is non-TTY).

Persistence (tmux). A ?session=<name> query param (validated ^[A-Za-z0-9_-]{1,64}$) makes the WS run the shell inside tmux (tmux new-session -A -s <name>, falling back to bash if tmux is absent), so the session survives WS disconnect/window close - proc.kill on disconnect kills the tmux client, the server daemon + session live on. The frontend keeps exactly one persistent slot at a time (ContainerTerminalManager, the last-opened terminal): it attaches to session devplace, auto-reconnects with capped backoff on unexpected WS close (not on code 1008), and is remembered per-user in localStorage (ct-persistent:<scope>); app.containerTerminals.restore() (called once in Application.js after window.app) reopens it on the next page load. Opening another terminal demotes/calls setPersistent(false) on the previous one (its tmux session lingers in the container, but the frontend stops auto-reconnecting/remembering it); explicit window-close (_onClose) detaches + forgets (session still survives, reopen re-attaches).

Right-click context menu. Every window wires app.contextMenu.attach(this.win, () => this._contextItems()) (right-click + long-press), opening the shared dp-context-menu. The base FloatingWindow._contextItems is Minimize/Normalize/Close; ContainerTerminal overrides it with Copy (xterm getSelection, disabled when !term.hasSelection()) / Paste (clipboard -> WS stdin) / Sync files / Restart / Terminate (dp-dialog confirm, then delete + close) / Minimize / Normalize / Project (-> /projects/{slug}) / Files (-> /projects/{slug}/files) / Close - the lifecycle items POST to the existing instances/{uid}/{sync,restart,delete} routes. Devii's _contextItems override returns Copy (live window.getSelection(), or the current input line when empty) / Paste (clipboard inserted into .devii-input at the caret, selectionStart/End splice, then refocus) / Minimize / Normalize / Close (it is not bound to a container or project). Copy/Paste both use the async Clipboard API with a hidden-textarea + execCommand("copy") write fallback.

Minimize/Normalize. Geometry presets (_presetGeometry(w,h), smallest-usable / comfortable), exposed both as titlebar buttons (data-win="minimize|normalize") and menu items on every window.

Pravda image (load-bearing, workspace ownership)

The ppy image (ppy.Dockerfile, built by make ppy, context devplacepy/services/containers/files) is a python:3.13-slim-bookworm base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (tmux, apache2-utils for ab, procps/htop/iftop/iotop, netcat-openbsd for nc, zip/unzip, fakeroot, git/curl/wget/vim/ack).

The security hotpatch that used to run per build is now baked into ppy.Dockerfile once. The final stage:

  • COPYs the sudo superclone (files/sudo) over /usr/local/bin/sudo (+ symlink /usr/bin/sudo; the real sudo package is not installed).
  • COPYs the aptroot fakeroot wrapper (files/aptroot, symlinked over apt/apt-get/dpkg in /usr/local/bin so pravda installs system packages without root).
  • COPYs pagent (files/pagent, the stdlib AI agent; reads DEVPLACE_OPENAI_URL+DEVPLACE_API_KEY, falling back to its public endpoint + DEEPSEEK_API_KEY) to /usr/bin/pagent.py, plus files/.vimrc to /home/pravda/.vimrc (whose AI helper - AiEditSelection - targets the same gateway as pagent via DEVPLACE_OPENAI_URL/DEVPLACE_API_KEY, with a public fallback, never api.openai.com).
  • Evicts any pre-existing uid-1000 user, creates user pravda at 1000:1000.
  • Hands pravda ownership of the toolchain AND the OS package trees (chown -R pravda over /usr/local/lib, /usr/local/bin, /usr/lib/python3, /opt, /app, /home/pravda, plus /usr/lib, /usr/bin, /usr/sbin, /usr/share, /usr/include, /etc, /var/lib, /var/cache, /var/log, /srv so apt/dpkg can write; ~/.local/bin on PATH).
  • Ends on USER pravda.

Why uid 1000. /app is bind-mounted from the host, and under DooD a container's UID maps 1:1 to the host. A container running as root wrote root-owned files into the workspace; the app process (host retoor, uid 1000) then hit [Errno 13] Permission denied in export_to_dir on the next instance create - a permanent per-project brick. Pinning the container UID to 1000 makes every write land as retoor, and pravda owning the global site-packages means runtime pip install succeeds as pravda with no elevation.

The sudo superclone (files/sudo, POSIX sh) is sudo-CLI-compatible but never swaps user: it parses the full flag interface (-u/-g/-E/-H/-n/-S/-i/-s/--, leading VAR=value env assignments, -V/-l/-v/-k/-K short-circuits), exports SUDO_USER/SUDO_UID/SUDO_GID/SUDO_COMMAND, then execs the command as the current user (pravda). So even a blind sudo apt ... / sudo -u root ... runs as uid 1000 and cannot create a root-owned file - the residual risk is gone by construction.

Rootless apt (files/aptroot). Pravda installs system packages directly (apt install <pkg>, no sudo). aptroot is symlinked over apt/apt-get/dpkg in /usr/local/bin (ahead of /usr/bin on PATH) and execs the real tool under fakeroot with APT::Sandbox::User=root; dpkg's chown-to-root calls are virtualized while every file actually written lands owned by pravda (uid 1000). Combined with pravda owning the system trees (/usr, /etc, /var/lib, /var/cache, /var/log, /srv, ...), apt install <pkg> works with no real euid 0 and still cannot brick the bind mount. Run apt update first (the image clears /var/lib/apt/lists); a package whose maintainer script needs genuinely privileged syscalls may still fail - bake those into ppy.Dockerfile (its RUN apt-get runs as root before USER pravda), then make ppy.

Trade-off (intentional). The only genuinely-root operation that still does NOT escalate is binding a port < 1024 - use a high port + /p/<slug> ingress instead. Enforcement lives entirely in the Dockerfile (no --user on docker run). The export_to_dir unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).

PRAVDA_* runtime env injection

api.run_spec_for merges api.pravda_env(instance) over the instance's own env_json (PRAVDA keys win), so every running container gets these platform vars:

  • DEVPLACE_BASE_URL - the site_url setting via seo.public_base_url().
  • DEVPLACE_OPENAI_URL - {base}/openai/v1, the OpenAI-compatible gateway base.
  • DEVPLACE_API_KEY - the creating user's (or run_as_uid's) api_key, resolved live.
  • DEVPLACE_USER_UID - the project owner's uid.
  • DEVPLACE_CONTAINER_NAME - the instance name.
  • DEVPLACE_CONTAINER_UID - the instance uid.
  • DEVPLACE_INGRESS_URL - the instance's absolute public ingress URL {base}/p/{ingress_slug} when published and site_url is set, relative /p/{slug} if site_url is unset, empty if the instance has no ingress_slug.

These let code inside a container call back into the platform and the AI gateway authenticated as the user. Injected at run (docker run -e), never baked into the image: the image is shared by every instance, but name/uid/api-key are per-instance and base-url/key must stay fresh, so they are resolved each launch and never stored as secrets in the DB.

Resolution needs two instance columns set at create_instance: created_by (= actor[1] when the actor is a user) feeds DEVPLACE_API_KEY, and owner_uid (= project["user_uid"]) feeds DEVPLACE_USER_UID. Instances created before this change have neither and degrade to an empty key/uid (base-url and container name/uid still resolve) until recreated. DEVPLACE_BASE_URL/DEVPLACE_OPENAI_URL are empty when site_url is unset, so set the admin site_url for container-to-platform calls (and ingress URLs) to work.

Ingress (/p/<slug>)

routers/proxy.py (registered at /p) implements the ingress reverse proxy. An instance opts in with ingress_slug + ingress_port (must be one of its mapped container ports; slug unique, validated in api.validate_ingress). _resolve(slug) -> store.find_instance_by_ingress -> api.proxy_target(instance) returns (gateway, host_port) (the instance's recorded docker bridge gateway + published host port); the route reverse-proxies HTTP (httpx) and WebSocket (websockets client) to http://{host}:{port}/{path}, stripping the /p/<slug> prefix. The reconciler records container_ip/container_gateway from docker inspect each running tick.

proxy_target dials the container's docker bridge gateway + the published host port (gateway:host_port), NOT loopback and NOT the container's own bridge IP. This is deliberate: 127.0.0.1 fails where docker's nat OUTPUT excludes 127.0.0.0/8 from DNAT and no docker-proxy binds loopback for a 0.0.0.0-published port; the container's own bridge IP can be dropped by docker's bridge-isolation rule (DOCKER ! -i docker0 -o docker0 -j DROP); the gateway+published-port path survives both. DEVPLACE_CONTAINER_PROXY_HOST overrides the host (e.g. host.docker.internal for a containerized app) and still uses the published host port; before a gateway is recorded the proxy falls back to 127.0.0.1.

Ingress is public (no auth) but SSRF-safe: host/port are derived from the instance row, never from user input. The Devii container tools return an absolute ingress_url built from seo.public_base_url() (the admin site_url setting), so the agent fetches the public production URL, not localhost (which web tools refuse); unset site_url yields a relative /p/<slug>. nginx needs a /p/ location with WS upgrade + long timeouts (present in nginx/nginx.conf.template).

Production wiring

The container manager drives the host docker daemon, which needs heavy wiring - all carried by the opt-in docker-compose.containers.yml overlay (docker socket mount, INSTALL_DOCKER_CLI=true build arg, group_add the docker gid). make docker-build/make docker-up always apply this overlay and self-derive its inputs: DOCKER_GID via stat -c '%g' /var/run/docker.sock (the socket's owning group) and DEVPLACE_DATA_DIR = $(CURDIR)/data (the project's own dir at its real host absolute path). This makes the manager work out of the box with no sudo, no /srv, no manual .env edits. A plain docker compose up -d drops the overlay (no CLI, no socket) and silently re-breaks it - always update through the make targets.

The DooD bind-mount gotcha: docker run -v <path>:/app resolves <path> on the HOST, so DEVPLACE_DATA_DIR must be mounted at an identical host+container path (the make targets use $(CURDIR)/data on both sides; manual docker compose users get a /srv/devplace-data default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".

Bidirectional newer-wins sync (load-bearing direction rule)

Sync is NOT one-directional import. project_files.sync_dir_bidirectional(project_uid, workspace, user) -> {"exported", "imported"} is the one helper that reconciles a project's virtual FS against an instance's workspace_dir: per file, the side with the newer timestamp wins (project updated_at epoch, via datetime.fromisoformat(...).timestamp(), vs filesystem st_mtime, with a 1s skew tolerance favouring export on ties), and a file present on only one side propagates to the other. It NEVER deletes a file - only creates/overwrites the older side. A read-only project (is_readonly) exports only, never imports (the read-only guard direction).

Both api.sync_workspace (HTTP/Devii sync action, returns {exported, imported}) and api.sync_bidirectional_sync (reconciler, non-blocking record_event system actor, logs only when non-zero) call the same helper. The reconciler runs it before every _launch AND on a ~60s wall-clock cadence over running instances (SYNC_EVERY_SECONDS, gated on time.monotonic() independent of the 5s reconcile tick). The per-instance boot-helper files (.devplace_boot.py/.devplace_boot.sh) are in SYNC_SKIP_NAMES so they never round-trip into the project.

Run-as user = identity + API key ONLY (load-bearing constraint)

An instance's run_as_uid column selects WHICH DevPlace user's identity and api_key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID), resolved in api.pravda_env ahead of the created_by/owner_uid fallback chain. It does NOT change the container OS user, which is ALWAYS pravda (uid 1000) - required for the bind-mounted /app (DooD uid maps 1:1 to host). Validate it against an existing user via api.validate_run_as.

Boot source precedence (load-bearing)

Columns boot_language (none|python|bash) + boot_script (multiline source) sit alongside the legacy boot_command. Precedence in api.run_spec_for: boot_script (by language) > boot_command > image CMD (sleep infinity). When a boot script is set, the reconciler writes it into the workspace (api.materialize_boot_script, .devplace_boot.py/.devplace_boot.sh, excluded from sync) before launch and runs python|bash /app/.devplace_boot.<ext>. api.validate_boot enforces the language set and a 100k char cap.

start_on_boot is per-container

The start_on_boot integer flag (0/1) forces desired_state=running ONLY for flagged instances when ContainerService first runs (_boot_pass, one-time); all others keep their last desired_state.

Status-change choke point

Every reconciler status mutation flows through service._set_status(inst, changes, reason=), which diffs old vs new status and, on change, writes an instance_events status_change row AND an audit container.instance.status record_system event (old_value/new_value). _reconcile, _launch, and _handle_exit (terminal + policy_restart) all flow through it, so previously-silent transitions (running->stopped/->paused/exit) are now logged and visible on the admin detail page's Status history card. New code that changes an instance's status in the reconciler must use _set_status, never a raw store.update_instance(... status ...).

New columns are ensured in init_db and defaulted in store.create_instance

run_as_uid/boot_language/boot_script/start_on_boot are added to the instances ensure-block in init_db (filtered/queried columns must exist on the cached schema) and seeded in the store.create_instance base dict so pre-existing rows degrade gracefully.

Testing without Docker

Use FakeBackend (its image_exists returns True) + runtime.set_backend, and monkeypatch config.CONTAINER_WORKSPACES_DIR to a tmp dir. See tests/unit/services/containers.py and tests/api/containers.py (argv, instance creation on config.CONTAINER_IMAGE, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).

Vibe coding on-ramp (user-facing doc)

The container runtime is also the basis of "vibe coding": the public prose page templates/docs/getting-started-vibing.html (slug getting-started-vibing, SECTION_GENERAL, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (create_project, container_create_instance, container_instance_action, the open_terminal client action), documents the three baked-in agents (dpc = DevPlace Code at /usr/bin/dpc, the Claude-Code-class coding agent; botje.py = the copy of services/containers/files/bot.py at /usr/bin/botje.py; pagent), all metered through the container's own DEVPLACE_API_KEY, the full PRAVDA_* env table (see api.pravda_env), and ingress at /p/<slug> via ingress_slug/ingress_port.

When the runtime, the agent binaries, or the PRAVDA_*/ingress contract change, update this page alongside the source.

The agents are gateway-only: dpc/d.py and botje.py/bot.py use a single molodetz backend pointed at DEVPLACE_OPENAI_URL (the gateway); the former direct api.deepseek.com fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses gateway_usage_ledger. pagent/.vimrc already posted to the gateway URL (using DEEPSEEK_API_KEY only as a key fallback, never the DeepSeek endpoint). Rebuild the image (make ppy) for the change to reach running containers.