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)
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.
Root cause: _ensure_web_bind_address injected --host 0.0.0.0 for code-server,
which only sets the bind host, not the port. code-server then listens on its
default port (8080) instead of the tool type's default_port (8443). Cloudflared
connects to port 8443 and gets connection refused, resulting in a 502.
Changes:
- _ensure_web_bind_address now accepts default_port and builds
--bind-addr 0.0.0.0:{port} for code-server
- Same fix for jupyter-notebook with explicit --port flag
- Existing broken --host commands are now detected and replaced
- New migration fixes tool_types templates and instance compose files on disk
- Test fixture updated to use correct --bind-addr 0.0.0.0:8443
- Inject explicit container_name into compose files at start/restart time
via _ensure_container_name_in_compose() to prevent Docker Compose from
generating UUID-based auto names that break backend network resolution.
- Use instance.name.lower() directly instead of get_container_name() lookups
which were unreliable with auto-generated names.
- Apply compose sanitization, bind-address fix, and container-name injection
on restart_instance as well so restarts pick up template fixes.
- Add --force-recreate to docker compose up to ensure container_name changes
take effect immediately.
- Fix notification lifecycle tests to match current behavior (success severity,
health_changed event for ownership test).
Quality gates: ruff clean, pytest (7 notification lifecycle tests passed)
FastAPI matches routes in declaration order. The DELETE /notifications
endpoint (bulk clear) was registered AFTER DELETE /notifications/{id},
so the path parameter route intercepted all requests to the bulk route,
causing a 422 UUID validation error instead of hitting clear_all.
Moved clear_all_notifications above dismiss_notification in the router.
Added regression test to verify route order.
Quality gates: pytest (22 passed)
Notification filtering:
- lifecycle_hooks.py: only instance.error and instance.health_changed
with status=running generate notifications. All other lifecycle events
(created, started, stopped, restarted, deleted) are filtered out.
- health_monitor.py: only error and unhealthy states generate notifications.
Running/recovered state no longer creates info notifications.
- _derive_title now maps instance.health_changed to "Container ready".
Clear-all button:
- Added dismiss_all() to NotificationService
- Added DELETE /notifications endpoint for bulk dismiss
- Frontend: clearAllNotifications API, clearAll in notification context,
"Clear all" button in notification drawer alongside "Mark all as read"
- Added CSS for .notification-clear-all with danger hover state
- Updated notification-center tests
Quality gates: pytest (21 passed), vitest (11 passed)
The --bind-addr flag caused code-server to fail entirely (app not
responding on any interface). The correct override for the
coder/code-server image is --host 0.0.0.0, which overrides the
entrypoint's --host 127.0.0.1.
Changes:
- Migration: Replace --bind-addr with --host 0.0.0.0, also handle
existing broken templates by detecting --bind-addr and replacing it
- Runtime safety net: _ensure_web_bind_address uses --host 0.0.0.0
- Test fixture: Updated compose template to match
Quality gates: pytest 42 passed
Root cause: code-server (and similar web tools) default to binding to
127.0.0.1 (localhost) inside their containers. This makes them unreachable
from the Docker network and from cloudflared, which connects via the
container's Docker network name.
Changes:
- Migration: Update code-server compose_template to include
--bind-addr 0.0.0.0:8443 command override
- Migration: Update jupyter-notebook compose_template to include
--ip=0.0.0.0 flag
- Runtime safety net: _ensure_web_bind_address() auto-injects bind
address for known web tools (code-server, jupyter-notebook) when
compose doesn't already specify a command
- Diagnostics: _check_app_binding() compares internal vs external
connectivity to detect 127.0.0.1 binding issues
- Improved readiness check: 30s timeout, checks HTTP status codes,
logs curl stderr for debugging
Files:
- apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py
- apps/api/src/services/docker.py
- apps/api/src/api/tool_instances.py
- apps/api/tests/integration/test_tool_types_api_extended.py
Quality gates: pytest 42 passed (5 pre-existing unrelated failures)
- apply_mount_permissions now skips mounts with readonly=true to avoid
'Read-only file system' warnings on post-start chown/chmod
- Removed the ssh_keys mount from the pi-agent manifest definition;
instance-level SSH key mounting now handles this exclusively
- Added unit test for read-only mount skipping
Quality gates: pytest (15 passed)
- start_instance now deep-merges manifest with base definition before extracting user.uid/user.gid
- The user config is typically defined in the base image (ubuntu-24.04-dev), not the extending manifest
- Add debug logging to verify resolved uid/gid/home_dir
- Add logging to prepare_ssh_key_files for chown success/failure visibility
- Log current process uid when chown fails to diagnose permission issues
Quality gates: pytest 239 passed (6 pre-existing failures), tsc --noEmit clean
- Extend prepare_ssh_key_files() with optional uid/gid parameters
- Call os.chown on created files when uid/gid are provided
- Gracefully handle PermissionError if API process is not root
- In start_instance, extract container user UID/GID from manifest
- Pass container UID/GID when preparing instance-level SSH key mounts
- Legacy clone-mode SSH keys continue to use root (0,0)
- Add unit tests for prepare_ssh_key_files ownership logic
- Keep apply_ssh_permissions() as fallback for cases where host chown fails
Quality gates: pytest 239 passed (6 pre-existing failures), tsc --noEmit clean
- Replace apply_ssh_permissions internals with _exec_and_log for full visibility
- Log every docker exec command, stdout, and stderr at DEBUG level
- After chown/chmod, run ls -la and stat to verify final state
- Log verified state at INFO level so users can see exactly what happened
- Update tests to mock subprocess.run instead of _run_in_container
Quality gates: pytest 236 passed (6 pre-existing), tsc --noEmit clean
- lifecycle_hooks.publish_lifecycle_event now skips notification creation
when event_type='instance.started' and status='starting'
- Users only see notifications for terminal states:
- Failed: instance.error
- Successful: instance.health_changed with status='running'
- Updated integration tests to verify new behavior:
- test_lifecycle_started_intermediate_skips_notification
- test_lifecycle_running_creates_notification
Quality gates: pytest 42 passed, ruff clean
- Add POST /config-profiles/validate-git-url endpoint:
- Parses URL using existing parse_git_url utility
- Suggests corrected URL for browser URLs
- Runs git ls-remote --heads to verify reachability
- Lists available branches from remote
- Supports SSH key for private repos
- Returns structured response: valid, suggested_url, branches,
default_branch, error, error_code
- Update frontend GitMountEditor:
- Add Check button next to URL field with loading state
- Show validation result: valid (green), suggestion (yellow),
invalid (red)
- Suggestion includes Use this button to apply corrected URL
- Branch field becomes dropdown when URL is validated,
populated with remote branches
- Mappings section disabled until URL is validated
- Shows hint: Validate the URL first
- Quality gates: pytest (218 passed, 6 pre-existing),
tsc --noEmit (clean)
- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration
Quality gates: pytest 23 new passed, ruff clean
The previous sorting fix exposed a deeper bug: ResolvedMount always
mounted its staging directory as a single bind mount. When a config
profile mount targeted /workspace/x/y and contained a single file
z.json, the staging directory (containing only z.json) replaced the
ENTIRE /workspace/x/y directory, hiding all sibling files from git
repo mounts.
- Change apply_resolved_profile to mount each file individually:
- source: staging_dir/relative_path
- target: expanded_target/relative_path
- Sibling files from other mounts are preserved.
- Empty mounts produce no volume entries.
- Keep volume sorting (parent paths before child paths) which is
still necessary for directory mounts and ensures parent dirs exist
before file mounts inside them.
- Add 4 unit tests for file-level mount behavior.
Quality gates: pytest (218 passed, 6 pre-existing), tsc --noEmit (clean)
- Add instance_events and health_checks tables with Alembic migration
- InstanceEventBus: typed pub/sub singleton with wildcard support
- HealthMonitor: async background loop polling containers every 15s
- SSE endpoint GET /events/stream with auth and connection limits
- Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete)
- Structured JSON logging with correlation IDs
- 15 new unit tests (EventBus, HealthMonitor, MonitoringModels)
Quality gates: pytest 15 new passed, ruff clean
- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application
Quality gates: pytest 188 passed, frontend typecheck clean
Addresses: home-path-expansion
- Add mappings array support to git_mount entries
- Clone repository once per git_mount entry, mount multiple subdirectories
- Normalize legacy source_path+target_path to mappings on read
- Update _merge_git_mounts to dedup by (remote_url, branch) and concatenate mappings
- Add _normalize_git_mount, _clone_git_repo, _resolve_git_mount_mappings helpers
- Update GitMountItem Pydantic model with GitMountMapping and model_validator
- Update frontend GitMountEditor component with mappings UI
- Auto-convert legacy git mount entries to mappings format on load
- Add 15 backend unit tests for normalization, resolution, and glob expansion
- Update existing config profile resolver tests for new merge behavior
Quality gates: pytest 167 passed, frontend typecheck clean
Addresses: config-profile-multi-repo-mounts
The frontend router navigates to /instances/:instanceId/terminal without
project_id or repo_id. The backend terminal REST endpoints were requiring
these path params, causing 404s.
- Simplify _get_terminal_instance to validate by instance_id only
- Update all REST routes from /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/*
to /instances/{instance_id}/terminal/*
- Update frontend API client to match new paths
- Update useTerminalSessions hook to take instanceId only
- Update TerminalPage to use simplified hook
- Update tests to match new paths
Fixes: 404 on GET /projects/repositories/instances/{id}/terminal/sessions
- Add test_tool_instances_legacy.py with 8 unit tests:
- dockerfile definition type builds from template
- dockerfile build failure raises HTTP 500
- compose definition type renders template
- manifest compiler is NOT called for legacy types
- start_instance legacy/compose/dockerfile types all skip manifest flow
- start_instance manifest type correctly invokes compiler
- Mark T3.2 and T3.3 tasks complete in OpenSpec
- Add openspec/docs/tool-workshop-guide.md with user guide covering
definition types, manifest creation workflow, base definitions,
migration path, and permissions
- Add ToolDefinitionManifest model with base image versioning
- Add manifest compiler: Dockerfile + Compose generation from JSON manifests
- Add permission fixer: post-start chown/chmod for mount policies
- Add tool definition CRUD API with live compile preview endpoint
- Integrate manifest-based startup flow in start_instance
- Add Alembic migration with data conversion for pi-agent
- Add 48 unit tests for manifest compiler, permission fixer, docker service
- Keep backward compatibility with legacy dockerfile_template/compose_template
Migration: applied successfully. Pi-agent converted to manifest.
Quality gates: pytest (146 passed, 4 pre-existing unrelated failures)
- Add WebSocket route /ws/tool-instances/{instance_id}/terminal/{session_id}
- Preserve /terminal as default-session alias for backward compatibility
- Extract shared _handle_terminal_websocket handler for both routes
- Add REST endpoints: GET list, POST create, DELETE close, POST reset, POST rename
- Preserve legacy POST .../terminal/reset as default session alias
- Add frontend API client (apps/web/src/api/terminal.ts)
- Add useTerminalSessions React hook for session CRUD + state management
- Add integration tests for auth requirements on all new endpoints
Quality gates: pytest (8 new passed, 182 total passed, 51 pre-existing failures)
- Add TerminalSessionModel DB table with instance_id FK, name, status,
created_at, last_activity_at, closed_at columns
- Add Alembic migration for terminal_sessions table
- Refactor TerminalManager to use composite key (instance_id, session_id)
supporting up to 5 concurrent sessions per instance
- Add create_session, get_session, get_sessions_for_instance, close_session
- Preserve get_or_create_session for backward compatibility (default session)
- Fix attach_websocket to only close sockets within same session
- Add name (auto-generated 'Session N') and status tracking to TerminalSession
- Add 7 unit tests for multi-session logic
Quality gates: pytest (7 new passed, 174 total passed, 51 pre-existing failures)
- get_container_id() and get_container_name() now lowercase the
instance name before passing to docker ps --filter, because
Docker container names are lowercase internally and the filter
is case-sensitive. This caused container_id to never be captured
when instance.name contained uppercase chars (e.g. 'Headquarter'),
breaking terminal WebSocket connections.
- Also guard proc.stdout being None in start_cloudflared_tunnel().
- Add unit tests for get_container_id and get_container_name.
Quality gates: pytest (14 passed), python clean
- Remove absolute path requirement from target_path validation
- Resolve relative paths against working_directory at instance startup
- Fall back to /home/user if no working_directory is configured
- Update frontend to allow relative target paths
- Update spec to document relative path support
- Update tests to allow relative paths and test path traversal rejection
- _checkout_branch now returns bool and falls back gracefully on failure
- Glob warning message includes matched file count
- Fix database model comment to reference remote_url
- Update tests for new branch checkout behavior
All 51 tests pass
- Change git mount schema from repo_id to remote_url
- Remove database lookups for git mount resolution
- Clone directly from URL at instance startup
- Simplify frontend UI to text input for Git URL
- Fix route ordering in git_repositories.py to prevent 422 errors
- Update all tests to use remote_url field
Breaking change: Git mounts now use remote_url instead of repo_id
- Add startup_command field to ToolType model and API
- Execute startup command before interactive shell in terminal sessions
- Add tmux and ranger to OpenCode container spec
- Update Tool Workshop UI with startup_command input for terminal types
- Add backend tests for startup_command CRUD operations
- Sync specs: tool-terminal, tool-types-definition, opencode-web-server
- New spec: tool-terminal-startup-command
Quality gates: Frontend typecheck/lint passed. Backend tests blocked by environment (Python/Docker not available).
OpenSpec: terminal-startup-and-container-tools
- Add ConfigProfile and ConfigProfileInclude data models with migrations
- Implement profile resolver service with ordered includes and merge rules
- Add profile CRUD API with validation, compatibility, and cycle detection
- Add instance API plumbing for profile selection on create/start/restart
- Add resolved profile preview and default resolution APIs
- Add frontend config profile API client and management UI
- Add launch/restart profile selection UI
- Add backend integration and unit tests (31 passing)
OpenSpec: add-config-profiles
Quality gates: ruff, TypeScript compile, 31 tests passing