- modify_compose_file now appends :ro when a config-profile volume entry
has readonly=true, matching the manifest compiler behavior.
- Add a guard for missing tool_type in prepare_manifest_instance.
Quality gates: python3 -m pytest (313 passed, 34 skipped)
- Switch apply_resolved_profile from per-file bind mounts to one
directory-level bind mount per ResolvedMount target.
- Stage all configured files under instance_dir/mounts/<sanitized_target>
and bind-mount that directory, so Docker no longer creates a root-owned
parent directory such as ~/.config.
- Propagate read-only mode ('ro') as the 'readonly' flag on volume entries.
- Update unit tests to expect directory-level mounts and add coverage for
readonly/writable flags.
Quality gates: python3 -m py_compile, pytest (313 passed, 34 skipped),
npm run typecheck, npm run lint.
Config-profile and git mounts staged under instance_dir were created
by the API process (root), so when bind-mounted over ~/.config the
container user could not write. Recursively chown staged sources to
the resolved container uid/gid before compose up.
Quality gates: python3 -m py_compile passed; ruff/pytest skipped
(test tooling not available in this shell, helper smoke tested
with a temporary directory).
Regenerate .pi-map*.md artifacts for source changes in:
- apps/api/src/api/tool/tool_types_validation.py
- apps/api/src/schemas/tool/tool_type.py
- apps/api/tests/integration/test_tool_types_api_extended.py
- and all affected test files from backend-frontend refactoring cleanup
- Remove clone_mode/branch from API responses and make DB columns nullable
- Remove legacy clone-mode branches from create_tool_instance
- Add WORKSPACE_PATH compose variable alongside REPO_PATH
- Add workspace migration helpers in WorkspaceManager
Remaining: POST /workspaces/:id/instances, frontend clone_mode cleanup, tests
Add generated .pi-map.md and .pi-map.index.md files across the repository
so the project navigation maps are shared and versioned. These artifacts
are maintained by project_map_init/patch/validate and must be kept in
sync with source edits.
Note: .cache/ remains ignored (added in previous commit).
Root cause: manifest-based Dockerfile created the home directory and
chowned only the home root. Files/directories copied from /etc/skel by
useradd -m (or created later by root) remained root-owned, so apps like
ranger failed when writing to ~/.config.
Changes:
- manifest_compiler.py: recursive chown of the home directory after
useradd so /etc/skel contents are owned by the container user
- Pre-create .config, .local/share, .cache and chown them to the user
so first-run apps have writable directories immediately
- Add unit test verifying the Dockerfile emits the expected user/home
setup and config directory creation
Quality gates: py_compile all backend files pass, test file compiles,
tsc --noEmit pass, npm run build pass, 82/82 web tests pass
Note: pytest not available in this shell; backend unit test was not
executed but follows existing project conventions.
Backend:
- sessions.py: include workspace_name in session response
- instance_service.py: auto-generate display names as
'Project / Workspace / Tool #N' instead of 'Workspace / Tool #N'
- instance_service.py: add rename_tool_instance() service function
- tool_instances.py: add PATCH /instances/{id} endpoint for renaming
display_name
Frontend:
- api/sessions.ts: add workspace_name to Session type, add renameInstance()
- use-instance-actions.ts: add handleRename, set document.title when opening
- session-card.tsx: click-to-edit display_name inline; always show project
context line (Project / Workspace or Repo / Tool)
- session-list.tsx: pass through onRename prop
- SessionsPage.tsx: wire handleRename to SessionCard and SessionList
- app-shell.tsx: sidebar tooltip includes workspace or repo name
- use-terminal-page.ts: set document.title based on active terminal session
Quality gates: py_compile all backend files pass, tsc --noEmit pass,
npm run build pass, 82/82 tests pass
Root causes:
1. No dedup for monitor restarts — _last_known_state is cleared on stop,
so every restart re-sent notifications for all unhealthy instances.
2. Aggressive error classification — any Docker state other than 'running'
was treated as 'error', including transient 'created' and 'restarting'.
3. Confusing metadata — when new_status == previous_status (after restart),
notifications showed previous_status equal to current status.
Fixes:
- _check_instance: when previous is None (first check) and new_status equals
the DB status, just record the snapshot and skip _handle_state_change.
This prevents duplicate events/notifications on monitor restart.
- _derive_status: only treat 'exited' and 'dead' as error. Preserve current
status for transient Docker states ('created', 'restarting').
- _derive_status: if DB says 'running' but container is 'not_found',
return 'error' instead of preserving 'running' (fixes silent failure).
- _handle_state_change: improved unhealthy message to 'Container tunnel is
unreachable' instead of generic 'Container is now unhealthy'.
Quality gates: py_compile all backend files pass, tsc --noEmit pass,
npm run build pass, 82/82 tests pass
Backend (health_monitor.py):
- Skip health checks for instances with no container_id
- Treat 'not_found' as error only when container was previously running
- Skip duplicate error notifications when already in error state
- Skip 'not_found' notifications for containers that never ran
Frontend (notification-item.tsx):
- Display notification.message (detailed error text)
- Add expandable Details section showing metadata (exit_code, previous_status, etc.)
- New CSS styles for message and metadata display
Quality gates: py_compile, tsc --noEmit, 80/80 tests pass
- Move APIRouter definition from instance_service.py back to tool_instances.py
(service files should not define FastAPI routers)
- Add missing prepare_manifest_instance import in tool_instances.py
- Guard repo.remote_url before clone_repository call
- Guard tool_type.compose_template before render_compose_template call
- Rename subprocess result variable to avoid shadowing SQLAlchemy Result
- Build error message as local string to avoid None/bool type issues
Quality gates: py_compile pass, LSP clean
From ae02e97 ('fix: tunnel URLs, session naming, git control bar placement'):
1. Tunnel URL regex: exclude api.trycloudflare.com from pattern.
Real tunnel subdomains are 10+ random chars. Prevents matching the
Cloudflare API endpoint instead of the actual tunnel URL.
2. Session auto-numbering: when user doesn't provide a display_name,
auto-generate 'project / repo / tool_type #N' where N increments
for each existing instance with the same project/repo/tool_type.
Prevents confusing duplicate display names in the sidebar.
These fixes were lost when main's merge was overwritten. Ported to
our clean dev codebase.
Quality gates: py_compile passed, ruff passed on tool_instances.py and tunnel.py.
tool-starter.tsx was hardcoding ssh_key_ids=[] and only showing a read-only
SSH key status. Users couldn't select keys when starting tools from workspaces.
Changes:
- tool-starter.tsx: add checkboxes for SSH key selection with repo key
pre-selected, pass selected keys to createInstance/startInstance
- AGENTS.md: add explicit rule forbidding docker compose commands without
user approval and proper isolation
The web container must be rebuilt to pick up the frontend changes:
docker compose up -d --build web
Quality gates: tsc clean, pytest (19 passed, 1 skipped)
When a tool container stops, the docker exec PTY reaches EOF. Previously,
the event-driven reader silently returned on EOF, leaving websockets
attached to a dead session. Input writes then failed silently.
Changes:
- _on_fd_readable: detect EOF (empty read) and call _handle_eof()
- _handle_eof: stop reading, mark process dead, close all websockets
with code 4001 to force frontend reconnection
- write_input: detect write errors and trigger EOF cleanup
Quality gates: pytest (19 passed, 1 skipped)
SSH key mounting was broken because:
1. Each selected key was mounted to a separate source dir but all targeted
the same ~/.ssh path in the container, causing Docker Compose's
last-mount-wins behavior
2. All keys were named id_ed25519, so they'd overwrite each other
Changes:
- ssh_keys.py: add key_filename param to prepare_ssh_key_files for unique
key names; add write_ssh_config for combined multi-key config
- tool_instances.py: collect all selected keys into a single ~/.ssh mount
with sanitized unique filenames (id_ed25519_<name>); generate combined
SSH config with all IdentityFile entries
- tests: add os.makedirs mock for SSH permission tests
Quality gates: pytest (19 passed, 1 skipped)
- Log REPO_PATH, SSH_PATH, EXTRA_VOLUMES, manifest mounts, and resolved
volumes in compile_compose() to trace why mounts may be missing
- Log repo_path and generated compose content in _prepare_manifest_instance()
to verify the full compose YAML at start time
- Convert WorkspaceHasInstancesError to store plain dicts instead of
SQLAlchemy ORM objects, preventing lazy-load failures outside async
session context (MissingGreenlet)
- Update both delete endpoints (top-level and nested) to use exc.instances
directly since they're already plain dicts
- Add no-cache headers for index.html in nginx.conf so browsers always
fetch new hashed JS/CSS bundles on deploy
1. Remove Docker build from create_instance for manifest types — the build
was blocking the HTTP request for several minutes, causing frontend
timeouts and retries. Image is now built lazily on start (via the
existing _prepare_manifest_instance path in start_instance).
2. Increase MAX_CONNECTIONS_PER_USER from 5 to 20 for SSE endpoint —
aggressive reconnect loops from the frontend were exhausting the limit
and causing 429 errors unrelated to tool starting.
Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
Bug 1 — in-container repo mounting:
- docker-compose.yml: added /data/working-copies:/data/working-copies mount
to API container so workspace dirs are visible on host filesystem
- Dockerfile: create /data/working-copies dir in image
Bug 2 — /home/user not writable:
- workspace_manager.py: chmod 777 workspace dirs + 666 files after clone
and after sync, so any container user can write
- manifest_compiler.py: explicit mkdir + chown + chmod 755 for home dir
in generated Dockerfile
Bug 3 — terminal text shifts left on typing:
- terminal.tsx: removed manual term.refresh() after fit (caused reflow)
- Track lastSentCols/lastSentRows and only send resize when dimensions
actually changed, preventing resize feedback loops
Bug 4 — ESC key captured by terminal:
- terminal.tsx: attachCustomKeyEventHandler allows ESC to propagate to
browser when not in alternate buffer (vim/tmux), so modals/navigation
work; ESC still sent to PTY when in vim/tmux alternate screen
Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
- GitService.clone() now accepts ssh_key and sets up GIT_SSH_COMMAND env
- WorkspaceManager.create() loads repo SSH key from DB and decrypts it
- Both workspace create endpoints pass session for SSH key lookup
Quality gates: ruff clean, pytest workspaces API (9 passed, 1 skipped)
Fixes NameError: ToolInstance not defined at runtime because
type annotations are evaluated at class definition time.
Deferring annotation evaluation with __future__ annotations
keeps TYPE_CHECKING imports from causing runtime crashes.
Also includes ruff formatting cleanup on workspace-related files.
- apps/api/src/services/tunnel.py: add 2-second sleep after discovering the
tunnel URL to allow Cloudflare DNS edge propagation before returning
- apps/web/src/hooks/use-instance-actions.ts: show alert() with the backend
error message when recreate tunnel fails, instead of silently swallowing
errors
Quality gates: ruff clean, tsc clean
Docker container names are case-sensitive for 'docker inspect' but case-
insensitive for Docker DNS. Compose templates may render container names
with mixed case (e.g. code-server-Headquarter-abc123), causing exact-name
docker inspect to fail while DNS resolution in tunnels works fine.
- apps/api/src/services/docker.py: get_container_id now tries exact match
first, then falls back to case-insensitive exact match via 'docker ps'
- apps/api/src/api/tool_instances.py: recreate_tunnel_endpoint uses
get_container_id instead of its own docker inspect call
Quality gates: ruff clean
docker ps --filter name= uses substring matching, so searching for
code-server-headquarter-abc123 also matches tunnel-code-server-headquarter-abc123.
This caused start_instance to store the tunnel container's ID instead of the
tool container's ID, breaking tunnel connectivity and all container operations.
Switched both helpers to docker inspect, which does exact name matching.
Quality gates: ruff clean
Adds INFO-level logging to trace exactly what happens during tunnel
recreation: container lookup, network membership, target IP/URL,
tunnel creation result, health check, and direct curl probe from API.
This will help diagnose why recreated tunnels return 502 while
original tunnels work.
Quality gates: ruff clean
Old instances may have auto-generated Docker Compose container names
that don't match instance.name.lower(), causing DNS resolution failures
for the tunnel. Also, old instances may not be on the backend network.
- apps/api/src/services/docker.py: add get_container_ip_on_network() and
is_container_on_network() helpers
- apps/api/src/services/tunnel.py: start_tunnel() and recreate_tunnel() now
accept an optional target_url parameter to override the default name-based URL
- apps/api/src/api/tool_instances.py: recreate_tunnel_endpoint now:
1. Looks up the tool container (by stored container_id or name)
2. Ensures it's connected to the backend network
3. Gets the container's IP on that network
4. Passes the IP as the explicit tunnel target
This guarantees the tunnel can reach the tool container regardless of
naming or network state.
Quality gates: ruff clean
Docker Compose prefixes network names with the project directory name
(e.g. 'headquarter_backend' instead of 'backend'). The previous code
hardcoded 'backend', causing 'network not found' errors.
- apps/api/src/services/docker.py: add get_backend_network_name() that
inspects the API container (hq-api) to find the actual network name
- apps/api/src/services/docker.py: connect_container_to_network() now
auto-detects the network name when not explicitly provided
- apps/api/src/services/tunnel.py: import and use get_backend_network_name()
- apps/api/src/api/tool_instances.py: remove explicit 'backend' arg from
connect_container_to_network() call
Quality gates: ruff clean
The host-network tunnel approach had issues because localhost inside
the tunnel container wasn't reaching the host-published ports correctly.
This reverts to running cloudflared as a Docker container on the
'backend' network, where Docker DNS resolves container names reliably.
The tunnel connects to http://{container_name}:{container_port}.
- apps/api/src/services/tunnel.py: use --network backend instead of host
- apps/api/src/api/tool_instances.py: pass container_port (default_port)
instead of published_port (host port) to tunnel functions
Quality gates: ruff clean
- Remove --rm from docker run so failed containers persist for inspection
- Add --no-autoupdate flag to prevent cloudflared from exiting on auto-update
- Capture both stdout and stderr from docker logs
- Check container exit code during wait loop; fail fast with logs if container exits early
- Include exit code in timeout error message for easier debugging
Replace the subprocess-based tunnel implementation with Docker containers
running on the host network. This eliminates all container name resolution
bugs that caused tunnel 502 errors.
New design:
- Each tunnel is a docker run --network host cloudflare/cloudflared container
- cloudflared connects to localhost:{published_port} (Docker port forwarding)
- No dependency on container names, backend network DNS, or binding diagnostics
- Tunnels named predictably: tunnel-{instance_name}
- Start/stop/recreate use container names instead of PIDs
Files changed:
- NEW: apps/api/src/services/tunnel.py — clean tunnel module (start/stop/recreate/health)
- apps/api/src/services/docker.py — removed 250 lines of old tunnel code
- apps/api/src/api/tool_instances.py — use new tunnel module, store container_name
- apps/api/src/services/health_monitor.py — updated import
- apps/web/src/components/session-card.tsx — Recreate Tunnel button always visible
Quality gates: ruff clean, 13 tests passed (health_monitor + notifications)