# SDD Exploration: Streamline Tool Container Definitions ## Status **Phase:** explore **Date:** 2026-05-28 **Owner:** el Gentleman (parent session) **Scope:** Tool container definition, build, and mount system --- ## Executive Summary The current tool container system works for the happy path (pi-agent on Ubuntu) but has deep structural inflexibility: 1. **Monolithic Dockerfile strings** in the database — impossible to review, version, or compose 2. **Ad-hoc compose generation** — string formatting with hardcoded fields (`stdin_open`, `tty`, `working_dir` missing for dockerfile types) 3. **Hardcoded mount paths** — `/workspace` and `/root/.ssh` don't adapt to the container's runtime user 4. **No package/base-image modularity** — every tool type carries a full Dockerfile copy 5. **Permission mismatch** — bind mounts come in as root-owned; non-root container users can't write 6. **Config overlap** — tool configs, config profiles, and compose templates fight for control of the same fields This exploration proposes a **layered, declarative container definition system** where tool types compose from reusable base images, mount schemas, and permission policies. --- ## Current Architecture Map ### Data Model ``` ToolType (DB table) ├── name, display_name, description, category ├── interface_type: "web" | "terminal" ├── definition_type: "dockerfile" | "compose" ├── dockerfile_template: TEXT (giant Dockerfile string) ├── compose_template: TEXT (Jinja-like {{VAR}} string) ├── build_context: JSON {path: content} ├── required_variables: JSON ["REPO_PATH", ...] ├── default_port: int └── readiness_probe: JSON ToolInstance (DB table) ├── name, display_name, status ├── tool_type_id → ToolType ├── repository_id → GitRepository ├── compose_path: str ├── container_id, container_name ├── port, url, public_url, tunnel_id ├── clone_mode: "mount" | "clone" ├── branch, new_branch └── selected_config_profile_id → ConfigProfile ToolConfig (DB table, per-user per-tool-type) ├── config_type: "env" | "file" ├── key, value, file_path ├── port_override, start_command, working_directory ├── environment_variables: JSON └── volumes: JSON [{source, target, type}] ConfigProfile (DB table) ├── name, description ├── user_id, project_id, tool_type_id ├── environment_variables: JSON ├── files: JSON {path: content} ├── mounts: JSON [{source, target, type}] ├── git_mounts: JSON [{remote_url, source_path, target_path, branch}] └── parent_profile_id → ConfigProfile (hierarchy) ``` ### Creation Flow (`create_instance`) ``` POST /projects/{id}/repositories/{id}/instances → validate tool_type, repo, config_profile → generate instance_name = "{tool_type}-{repo}-{uuid8}" → ensure_instance_directory(instance_name) → find_free_port() → determine repo_path (mount = repo.path; clone = clone_repository()) → IF tool_type.definition_type == "dockerfile": build_image(instance_dir, dockerfile_template, tag, build_context) generate compose_content (HARDCODED STRING FORMATTING) ELSE: render_compose_template(tool_type.compose_template, variables) → write_compose_file() → create ToolInstance DB record (status="pending") ``` ### Startup Flow (`start_instance`) ``` POST /instances/{id}/start → fetch ToolConfigs (env, files, port_override, start_command, working_dir, volumes) → IF selected_config_profile: resolve_profile() → env, files, mounts, git_mounts, hints → write .env file, config files → IF clone_mode: mount SSH keys at /root/.ssh (HARDCODED) → _modify_compose_file(port, command, working_dir, extra_volumes) → _sanitize_compose_file() → execute_compose_command("up") → get_container_id(instance.name) ← CASE-SENSITIVE BUG (fixed) → get_container_name(instance.name) → connect_container_to_network("backend") → wait_for_container_running() → IF web: start_cloudflared_tunnel() → instance.status = "running" ``` ### Key Files | File | Responsibility | |------|---------------| | `apps/api/src/api/tool_instances.py` | create_instance, start_instance, stop_instance, restart_instance, proxy, logs | | `apps/api/src/api/tool_types.py` | CRUD for ToolType (DB strings) | | `apps/api/src/services/docker.py` | compose execution, container queries, tunnel management | | `apps/api/src/services/docker_build.py` | `docker build` wrapper | | `apps/api/src/services/terminal_session.py` | PTY-based terminal over `docker exec` | | `apps/api/src/services/terminal_manager.py` | WebSocket ↔ terminal session lifecycle | | `apps/api/src/models/tool_type.py` | SQLAlchemy model | --- ## Pain Points (Detailed) ### 1. Monolithic Dockerfile Templates The pi-agent Dockerfile template is a 40-line string stored in the DB migration: ```sql INSERT INTO tool_types (... dockerfile_template ...) VALUES ('...# Pi Coding Agent - Terminal-based coding harness\nFROM ubuntu:24.04\n...') ``` **Problems:** - No syntax highlighting, linting, or `docker build` validation at edit time - Every tool type copies the entire Dockerfile; no reuse of common layers - Changes require a DB migration - No way for users to customize packages without forking the whole template ### 2. Ad-Hoc Compose Generation For `dockerfile` type tools, the compose is generated by Python f-string: ```python compose_content = f"""version: "3.8" services: app: image: {image_tag} container_name: {instance_name.lower()} {ports_section} volumes: - {repo_path}:/workspace restart: unless-stopped """ ``` **Problems:** - Missing `stdin_open: true` and `tty: true` (essential for terminal tools) - Missing `working_dir: /workspace` - No way to add labels, networks, healthchecks, or extra services - Port section is conditionally included with awkward string concatenation ### 3. Hardcoded Mount Paths | Mount | Current Target | Problem | |-------|---------------|---------| | Repository | `/workspace` | Always root-owned; no permission fix for non-root users | | SSH keys (clone mode) | `/root/.ssh` | Invisible to containers running as `user` | | Git-mount configs | `/tmp/.pi` | May be root-owned; conflicts with user's `.pi` | | Config profile files | Instance-relative paths | No validation against container filesystem | ### 4. No Base Image / Layer Composition Every tool type must specify a complete Dockerfile from `FROM` to `CMD`. There's no way to say: ```yaml base: ubuntu-24.04-dev # pre-built with curl, git, build-essential layers: - nodejs-20 - pi-coding-agent - custom-packages: [neovim, ranger, tmux] ``` ### 5. Permission Mismatch (Non-Root Users) The pi-agent Dockerfile creates a `user` account and uses `USER user`. Bind mounts from the host come in as root-owned. The API has **no automatic permission fix** — this caused the workspace-unwritable bug. Workarounds considered: - Post-start `docker exec --user root chown` (current fix) - Dockerfile entrypoint script that chowns before dropping privileges - Matching container UID to host UID None of these are systematic or configurable. ### 6. Config Overlap and Precedence Confusion Three systems control the same container aspects: | System | Controls | Stored | |--------|----------|--------| | ToolConfig | env vars, files, port, command, working_dir, volumes | DB (per-user per-tool) | | ConfigProfile | env vars, files, mounts, git_mounts, hints | DB (hierarchical) | | Compose template / generation | volumes, ports, command, working_dir | DB string / Python f-string | **Precedence is unclear:** - ToolConfig `working_directory` vs ConfigProfile hint `working_directory` vs compose `working_dir` - ToolConfig `volumes` vs ConfigProfile `mounts` vs compose `volumes` - `start_command` from ToolConfig vs ConfigProfile vs Dockerfile `CMD` ### 7. Build Context Limitations `build_context` is a JSON dictionary of `{relative_path: file_content}`. This is stored in the DB as text. **Problems:** - Binary files (images, tarballs) can't be stored - Large files bloat the DB - No versioning or external reference (e.g., "use file from git repo") --- ## Extensibility Gaps | Want | Current State | Gap | |------|--------------|-----| | Add a new language runtime (e.g., Go, Rust) | Copy entire Dockerfile, edit | No modular package/layer system | | Use a custom base image (e.g., `my-registry/dev-base:v2`) | Edit full Dockerfile | No base-image reference field | | Mount a second repo or a secrets file | Write ConfigProfile or ToolConfig JSON | No declarative mount schema | | Run as root instead of `user` | Edit full Dockerfile | No runtime-user field | | Add a sidecar (e.g., postgres for integration tests) | Edit compose_template string | No multi-service compose support | | Pre-install VS Code server | Edit full Dockerfile | No "feature" or "extension" mechanism | | Custom entrypoint script | Edit full Dockerfile | No entrypoint field | --- ## Design Directions (Pre-Proposal) ### Direction A: Declarative Tool Manifests Replace the monolithic `dockerfile_template` with a structured manifest: ```yaml # Example: tool manifest for pi-agent name: pi-agent base_image: ubuntu:24.04 user: name: user uid: 1000 home: /home/user packages: apt: [curl, wget, git, neovim, ranger, tmux, htop, tree, jq, python3, python3-pip, build-essential] npm_global: [@earendil-works/pi-coding-agent] node_version: "20" env: DEBIAN_FRONTEND: noninteractive config_files: /home/user/.tmux.conf: "set -g mouse on\n..." /home/user/.config/ranger/rc.conf: "set preview_files true\n..." working_directory: /workspace command: ["/bin/bash"] ports: [] mounts: repo: {target: /workspace, writable: true} ssh: {target: /home/user/.ssh, mode: "0600"} tmp_state: {target: /tmp/.pi, writable: true} ``` **Pros:** Structured, reviewable, composable **Cons:** Requires a manifest-to-Dockerfile compiler; migration complexity ### Direction B: Base Image Registry + Layers Maintain a registry of pre-built base images: ``` headquarter/base/ubuntu-24.04-dev headquarter/base/nodejs-20 headquarter/base/python-3.11 ``` Tool types reference a base image and a list of layers: ```yaml base_image: headquarter/base/ubuntu-24.04-dev layers: - type: npm_install package: @earendil-works/pi-coding-agent - type: config_file path: /home/user/.tmux.conf content: "..." ``` **Pros:** Fast builds (base images cached), reusable, versioned **Cons:** Requires image registry management, layer ordering complexity ### Direction C: Compose-First with Dockerfile Overrides Treat `compose_template` as the primary definition. For simple cases, use a pre-built image. For custom cases, allow an inline Dockerfile or a `build` section in the compose: ```yaml services: app: build: context: . dockerfile_inline: | FROM ubuntu:24.04 ... stdin_open: true tty: true working_dir: /workspace volumes: - ${REPO_PATH}:/workspace - ${SSH_PATH}:/home/user/.ssh:ro user: "${CONTAINER_USER:-user}" ``` **Pros:** Leverages Docker Compose native features, familiar to users **Cons:** Still string-based; inline Dockerfiles are hard to edit ### Direction D: Permission-Aware Mount Schema Decouple mount declaration from mount implementation: ```python class MountPolicy: source: str # host path target: str # container path owner: str | None # container user to own the mount permissions: str # chmod string readonly: bool ``` At startup, the API runs a post-start "permission fixer" that applies all policies: ```bash docker exec --user root chown -R docker exec --user root chmod ``` **Pros:** Systematic, works with any base image, configurable per mount **Cons:** Adds startup latency, requires root to exist in container --- ## Recommended Next Steps 1. **Proposal phase:** Evaluate Direction A (Declarative Manifests) vs Direction C (Compose-First) for the primary architecture 2. **Design phase:** Detail the manifest schema or compose enhancement, migration path, and API changes 3. **Consider Direction D** as a cross-cutting concern regardless of primary direction --- ## Risks - **Migration risk:** Existing `dockerfile_template` and `compose_template` columns need backward-compatible migration - **Build cache invalidation:** Changing the build system may invalidate Docker layer caches - **User confusion:** Adding a manifest layer on top of Dockerfiles may feel like "yet another abstraction" - **Scope creep:** This touches tool types, tool configs, config profiles, compose generation, and the startup flow — high cross-cutting surface --- ## Artifacts - `openspec/config.yaml` — SDD configuration - `openspec/explorations/streamline-tool-container-definitions.md` — This document