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:
instancesinstance_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:
backend.ps(label=devplace.instance)snapshotsdocker ps(labeldevplace.instance=<uid>is the join key).- Converge each instance to its
desired_state. - Apply restart policies.
- Reap orphan containers (labeled but no DB row -> no orphans, no lost state).
- Fire due
instance_schedules(reusingdevii/tasks/schedule.pycron_next/next_run). - 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- therouters/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 sharedppyimage. Discoverable from the project detail page's admin-only Containers button (gated bycontent.can_view_project_containersvia theviewer_can_containerscontext 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}/editedits 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).
- Per-project manager:
templates/containers.html+static/js/ContainerManager.jshandles instance creation through an app modal form (the_macros.htmlmodal()macro +ModalManager.visibletoggle); 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. - Admin Containers section:
routers/admin/containers.py(mounted/admin/containers, sidebar link inadmin_base.html,admin_section="containers").GET /admin/containerslists every instance viastore.all_instances()(decorated with project title/slug from oneprojectslookup) in an.admin-table.GET /admin/containers/datais the poll JSON.GET /admin/containers/{uid}renderstemplates/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, editsrun_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 viadb.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 realsudopackage is not installed).COPYs theaptrootfakeroot wrapper (files/aptroot, symlinked overapt/apt-get/dpkgin/usr/local/binso pravda installs system packages without root).COPYspagent(files/pagent, the stdlib AI agent; readsDEVPLACE_OPENAI_URL+DEVPLACE_API_KEY, falling back to its public endpoint +DEEPSEEK_API_KEY) to/usr/bin/pagent.py, plusfiles/.vimrcto/home/pravda/.vimrc(whose AI helper -AiEditSelection- targets the same gateway as pagent viaDEVPLACE_OPENAI_URL/DEVPLACE_API_KEY, with a public fallback, neverapi.openai.com).- Evicts any pre-existing uid-1000 user, creates user
pravdaat1000:1000. - Hands pravda ownership of the toolchain AND the OS package trees (
chown -R pravdaover/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,/srvsoapt/dpkgcan write;~/.local/binonPATH). - 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- thesite_urlsetting viaseo.public_base_url().DEVPLACE_OPENAI_URL-{base}/openai/v1, the OpenAI-compatible gateway base.DEVPLACE_API_KEY- the creating user's (orrun_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 andsite_urlis set, relative/p/{slug}ifsite_urlis unset, empty if the instance has noingress_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.