Compare commits

...

35 Commits

Author SHA1 Message Date
alex 391dd5ee80 docs: refresh README 2026-07-27 15:24:01 +02:00
alex d8d44f0c6a merge: suppress competing terminal context menus 2026-07-22 14:56:04 +02:00
alex bf561186fb fix(terminal): suppress competing context menus
Stop contextmenu propagation after recognizing selected xterm output so the native browser overlay cannot block Copy.
2026-07-22 14:56:03 +02:00
alex 0afce741eb merge: prioritize terminal selected copy menu 2026-07-22 14:25:21 +02:00
alex 7603b739cb fix(terminal): prioritize selected copy menu
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
2026-07-22 14:25:20 +02:00
alex ff8efdd887 merge: add terminal right click copy action 2026-07-22 14:15:47 +02:00
alex b289edf5a9 fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
2026-07-22 14:15:46 +02:00
alex 75b67f2a6f merge: use ctrl shift c for terminal output copy 2026-07-22 13:01:32 +02:00
alex bcc7486b59 fix(terminal): use ctrl shift c for output copy
Capture Ctrl+Shift+C before xterm input handling and copy selected output through a browser-compatible fallback. Preserve Ctrl+C as terminal input.
2026-07-22 13:01:31 +02:00
alex 29e765b2be merge: support terminal browser clipboard shortcuts 2026-07-22 12:52:20 +02:00
alex d14cdc1151 fix(terminal): support browser clipboard shortcuts
Copy selected terminal output with Ctrl/Cmd+C without sending an interrupt, and route browser paste consistently through the terminal transport.
2026-07-22 12:52:19 +02:00
alex c80dbf9737 merge: preserve mounted profile file updates 2026-07-22 11:52:43 +02:00
alex 5331a0f110 fix(config-profiles): preserve mounted file inodes
Update files within profile directory mounts in place so editor saves reach running containers.
2026-07-22 11:52:42 +02:00
alex b995521e22 merge: preserve profile file bind mount updates 2026-07-22 11:37:54 +02:00
alex 5610017f50 fix(config-profiles): preserve bound file inodes
Overwrite individually bind-mounted profile files in place so editor saves remain visible to running containers.
2026-07-22 11:37:53 +02:00
alex 61e7d68d71 merge: synchronize shared profile mount working copies 2026-07-22 11:23:34 +02:00
alex 2247ec47c9 fix(config-profiles): synchronize shared mount working copies
Use canonical profile files and writable Git working copies so editor and container changes share one source. Require confirmation before destructive Git refreshes and overlay profile files without composite snapshots.
2026-07-22 11:23:19 +02:00
alex 0d6c1926ae merge: add Git mount refresh feedback 2026-07-21 21:18:25 +02:00
alex 900a8e47a5 fix(config-profiles): show Git mount refresh progress
Disable duplicate refresh requests and show in-progress feedback while Git mount sources are refreshed.
2026-07-21 21:14:08 +02:00
alex 25e870ba43 merge: fix container mount layering 2026-07-21 20:54:44 +02:00
alex 16984b7cf6 fix(containers): compose profile and Git mounts safely
Stage profile sources per instance and compose overlapping bind mounts so Docker cannot mask Git content or leave writable files root-owned.\n\n- preserve shared Git clones while applying profile overlays\n- add mount composition and ownership regression coverage\n- update OpenSpec tracking
2026-07-21 20:49:03 +02:00
Developer fc52353b2e fix: break config profile refresh import cycle 2026-07-21 15:46:35 +00:00
Developer a63a983116 feat: merge live Git config mount refresh 2026-07-21 15:37:34 +00:00
Developer 886c863260 feat: refresh shared Git config mounts live
- Use profile-scoped canonical Git clone sources with locked refreshes
- Mount shared Git configuration read-only and isolate profile content
- Add API and desktop/mobile actions for live Git mount refresh

Quality gates: frontend build and backend py_compile passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
2026-07-21 14:37:42 +00:00
Developer 3c25fffd49 feat: merge live config profile refresh 2026-07-21 11:36:56 +00:00
Developer add7c1b500 feat: add live config profile refresh
- Standardize built-in tool users for shared writable profile mounts
- Mount canonical non-Git profile sources across compatible instances
- Report restart-required outcomes and guard active profile deletion
- Surface restart feedback in config profile editing

Quality gates: frontend build passed; backend py_compile and LSP passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
2026-07-21 11:12:41 +00:00
Developer f9f9372ee7 fix: merge web terminal resilience 2026-07-18 10:32:56 +00:00
Developer ea42165ed2 fix: harden web terminal paste and reconnect
- Queue bounded ordered terminal input so acknowledgements remain responsive
- Prevent stale sockets and retries from replacing healthy connections
- Preserve desktop scrollback behavior and add terminal regression coverage

Quality gates: frontend tests (91 passed), typecheck, lint, build, Python compilation, LSP diagnostics. Backend pytest skipped by user request.
2026-07-17 22:11:24 +00:00
Developer c178225c8b Merge branch 'fix/git-mount-clone-reuse' into dev 2026-07-17 20:54:21 +00:00
Developer b5e961ebe9 fix: reuse cached config profile git mounts
- Reuse valid deterministic git mount clones on repeated starts
- Remove incomplete clone destinations before retrying
- Add regression coverage for cached and partial clones
- Document OpenSpec change fix-config-profile-git-mount-clone-reuse

Quality gates: pytest (13 passed), ruff, mypy
2026-07-17 20:54:01 +00:00
Developer 5d379c5f8b merge: add Headquarter favicon 2026-07-15 13:06:41 +00:00
Developer 671540ded9 feat: add Headquarter favicon
- Add a geometric evergreen Headquarter mark for browser tabs\n- Register the SVG favicon and matching browser theme color\n\nOpenSpec: add-project-favicon\nQuality gates: npm run build
2026-07-15 13:06:40 +00:00
Developer ad26fd9f35 merge: dialog scroll containment 2026-07-15 10:20:39 +00:00
Developer bb38b37ceb fix: reinitialize terminal on mobile viewport changes
Recreate the terminal when responsive classification changes so mobile scrollback and touch listeners are installed.\n\nOpenSpec: fix-mobile-terminal-scrolling\nQuality gates: npm run typecheck, npm run lint, npm test (89 passed), npm run build
2026-07-14 19:41:34 +00:00
Developer 6698c20f25 fix: restore mobile terminal scrolling
- Retain xterm normal-buffer history on mobile while preserving desktop zero-scrollback behavior\n- Repair ProjectsPage tests for session context and current project list markup\n- Add focused terminal scrollback coverage\n\nOpenSpec: fix-mobile-terminal-scrolling\nQuality gates: npm run typecheck, npm run lint, npm test (89 passed), npm run build
2026-07-14 19:07:02 +00:00
50 changed files with 2066 additions and 561 deletions
+84 -146
View File
@@ -1,150 +1,61 @@
# Headquarter
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
Headquarter is a self-hosted workspace for managing projects, Git repositories, development-tool instances, SSH keys, and user preferences. It has a FastAPI API, a React frontend, PostgreSQL and Redis, and Authentik OAuth2/session authentication. Built-in tool definitions include code-server and Jupyter.
## Overview
## Prerequisites
Headquarter provides a centralized workspace for development teams to:
- Manage projects and their associated git repositories
- Browse repository files and view git history
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
- Manage SSH keys and user preferences
- Docker and Docker Compose for the provided local and production Compose stacks.
- Git for repository workflows.
- Python 3.11 or newer for manual API development.
- Node.js and npm for manual frontend development.
- An Authentik configuration for the authenticated deployment.
## Features
Production additionally requires an existing Traefik network and host Docker access for tool-instance management.
### Project Management
- Create and manage projects
- View all projects in a dashboard
- Click any project to open its workspace
## Local Compose setup
### Git Repository Management
- Initialize bare repositories
- Clone repositories (including mirror clones)
- Smart URL parsing (converts browser URLs to git URLs)
- View repository history and commit details
1. Create a local environment file from the template and replace placeholder credentials before using a shared or production-like environment:
### Repository Workspace
- Browse files and directories
- View file contents with syntax highlighting
- Switch between branches
- Quick file editing with automatic commits
### Git History Visualization
- View commit history with branch graph
- See commit details, statistics, and diffs
- Filter by branch
### Authentication
- OAuth2 via Authentik
- Session-based authentication
- User profile management
### Tool Management
- Built-in tool types (code-server, jupyter-notebook)
- Create custom tool types with Docker Compose templates
- Template validation
### User Settings
- Theme selection (system/light/dark)
- Git identity configuration
- Default editor preference
### SSH Key Management
- Generate Ed25519 key pairs
- Copy public keys to clipboard
- Delete keys
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git
### Local Development
1. **Clone the repository:**
```bash
git clone <repository-url>
cd headquarter
```
2. **Set up environment:**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Start services:**
2. Start the local stack:
```bash
docker compose up -d
```
4. **Access the application:**
- Frontend: http://localhost:5173
- API: http://localhost:8000
- API Docs: http://localhost:8000/docs
The local Compose stack starts PostgreSQL, Redis, the API, and the web frontend. It publishes the web frontend at <http://localhost:3000>, the API at <http://localhost:8000>, and API documentation at <http://localhost:8000/docs>. PostgreSQL and Redis are also published on ports 5432 and 6379 respectively.
### Production Deployment
> **Authentication limitation:** this command does not configure the `AUTHENTIK_*`, `API_DOMAIN`, or `WEB_DOMAIN` values required for a verified authenticated flow. Treat authenticated local use as unsupported until those values are supplied through a documented local configuration.
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
Useful operational commands:
## Tech Stack
### Backend
- **FastAPI** - Python web framework
- **SQLAlchemy** - ORM with async PostgreSQL support
- **Pydantic** - Data validation
- **Alembic** - Database migrations
- **python-jose** - JWT handling
### Frontend
- **React** - UI library
- **TypeScript** - Type safety
- **Vite** - Build tool
- **React Router** - Client-side routing
### Infrastructure
- **Docker** - Containerization
- **PostgreSQL** - Database
- **Traefik** - Reverse proxy (production)
- **Authentik** - Identity provider
## Documentation
- [User Guide](docs/features/) - Feature documentation
- [API Reference](docs/api/) - API endpoints
- [Architecture](docs/architecture/) - System design
- [Deployment](docs/deployment/) - Setup guides
- [Development](docs/development/) - Contributing
## Project Structure
```
.
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── src/
│ │ │ ├── api/ # API routes
│ │ │ ├── auth/ # Authentication
│ │ │ ├── models/ # Database models
│ │ │ └── utils/ # Utilities
│ │ ├── tests/ # Test suite
│ │ └── Dockerfile
│ └── web/ # React frontend
│ ├── src/
│ │ ├── api/ # API clients
│ │ ├── components/# UI components
│ │ └── pages/ # Page components
│ └── Dockerfile
├── docs/ # Documentation
├── docker-compose.yml # Development setup
├── docker-compose.traefik.yml # Production setup
└── Makefile # Common commands
```bash
make up # Start services
make down # Stop services
make logs # Follow Compose logs
make migrate # Apply database migrations in the API container
make health # Show Compose service status
```
## Development
## Production deployment
The production Compose file is [`docker-compose.traefik.yml`](docker-compose.traefik.yml). It expects an existing external Traefik network (named `traefik` by default), configured domains, an Authentik client secret, and host paths for repositories, working copies, and tool instances.
After preparing `.env` with production values, deploy with:
```bash
docker compose -f docker-compose.traefik.yml up -d
```
The production API container mounts `/var/run/docker.sock` so it can manage tool instances. Treat this as privileged host access and restrict it appropriately. The deployment guide and supporting material are under [`docs/deployment/`](docs/deployment/).
## Manual development
### API
### Backend Development
```bash
cd apps/api
python -m venv .venv
@@ -153,43 +64,70 @@ pip install -e ".[dev]"
uvicorn src.main:app --reload
```
### Frontend Development
### Web
```bash
cd apps/web
npm install
npm run dev
```
### Running Tests
## Tests and quality checks
The Make targets run commands in the Compose services:
```bash
# Backend tests
make test
# Frontend tests
make test-web
# All quality gates
make test-unit
make test-integration
make test-system
make test-e2e
make lint
make typecheck
make build
```
The frontend test command is run directly from its package directory:
```bash
cd apps/web
npm run test
```
`make lint` runs API Ruff and mypy checks plus frontend linting; `make typecheck` runs API mypy and frontend typechecking. Neither target runs frontend tests.
## Configuration
Key environment variables:
Copy [`.env.example`](.env.example) to `.env` and replace its placeholder values rather than committing credentials. Key settings include:
| Variable | Description | Default |
|----------|-------------|---------|
| `API_DOMAIN` | API domain | `localhost` |
| `WEB_DOMAIN` | Web domain | `localhost` |
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
| `DATABASE_URL` | PostgreSQL URL | - |
| `JWT_SECRET` | JWT signing secret | - |
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
| Setting | Purpose |
| --- | --- |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | PostgreSQL connection settings |
| `REDIS_URL` | Redis connection URL |
| `SESSION_SECRET`, `SESSION_TTL_HOURS` | Session signing and lifetime |
| `API_DOMAIN`, `WEB_DOMAIN` | Public API and web domains |
| `AUTHENTIK_DOMAIN`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth configuration |
| `AUTHENTIK_APPLICATION_SLUG` | Authentik application path component |
| `VITE_API_BASE_URL`, `VITE_APP_URL` | Frontend build-time public URLs |
| `REPO_BASE_PATH` | Repository storage path |
| `TRAEFIK_NETWORK` | Existing Traefik network for the production Compose stack |
See [Environment Variables](docs/deployment/environment.md) for complete list.
The API reads `.env` settings and has development defaults, but defaults such as local credentials and session secrets are not suitable for production.
## Repository layout
```text
.
├── apps/
│ ├── api/ # FastAPI API, migrations, and tests
│ └── web/ # React/Vite frontend
├── docs/ # Architecture, API, feature, deployment, and development docs
├── e2e/ # Playwright end-to-end tests
├── docker-compose.yml # Local Compose stack
├── docker-compose.traefik.yml # Traefik deployment stack
└── Makefile # Compose, test, quality, and build commands
```
## License
[License information]
No license file or declared license was found in this checkout.
+126 -4
View File
@@ -1,7 +1,9 @@
"""Config profile API endpoints."""
import logging
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
@@ -9,7 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
from src.config import Settings
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -21,6 +24,7 @@ from src.schemas.config import (
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
apply_resolved_profile,
resolve_profile,
resolved_profile_to_dict,
)
@@ -43,6 +47,59 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
def _canonical_profile_response(profile: ConfigProfile) -> dict:
"""Return profile data with edits from its shared working copy."""
response = profile_to_response(profile)
root = Path(Settings().instance_base_path) / "config-profiles" / str(profile.id)
def read_file(path: Path, fallback: str) -> str:
try:
return path.read_text() if path.is_file() else fallback
except OSError:
return fallback
response["files"] = {
relative_path: read_file(root / "files" / relative_path, content)
for relative_path, content in response["files"].items()
}
response["mounts"] = [dict(mount) for mount in response["mounts"]]
for mount in response["mounts"]:
mount_root = root / "mounts" / mount["target"].lstrip("/").replace("/", "_")
mount["files"] = {
relative_path: read_file(mount_root / relative_path, content)
for relative_path, content in mount.get("files", {}).items()
}
return response
async def _running_profile_outcomes(
session: AsyncSession, profile_id: uuid.UUID
) -> list[dict[str, str]]:
"""Report running instances that must restart to adopt a profile revision."""
result = await session.execute(
select(ToolInstance).where(ToolInstance.status == "running")
)
outcomes: list[dict[str, str]] = []
for instance in result.scalars().all():
if instance.selected_config_profile_id is None:
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
dependencies = {resolved.profile_id} | {
uuid.UUID(item["id"])
for item in resolved.included_profiles
if item.get("id")
}
if profile_id in dependencies:
outcomes.append(
{
"instance_id": str(instance.id),
"status": "restart_required",
"reason": "Existing instance must restart to adopt shared profile mounts",
}
)
return outcomes
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
@@ -85,7 +142,7 @@ async def list_config_profiles(
result = await session.execute(query)
profiles = result.scalars().all()
return [profile_to_response(p) for p in profiles]
return [_canonical_profile_response(profile) for profile in profiles]
@router.post(
@@ -118,7 +175,7 @@ async def get_config_profile(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
return profile_to_response(profile)
return _canonical_profile_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
@@ -140,8 +197,63 @@ async def update_config_profile(
)
profile = await update_profile(session, profile, data)
resolved = await resolve_profile(session, profile.id)
apply_resolved_profile(
os.path.join(Settings().instance_base_path, "profile-refresh"),
resolved,
)
response = _canonical_profile_response(profile)
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
logger.debug("Updated config profile %s", profile.id)
return profile_to_response(profile)
return response
@router.post("/{profile_id}/refresh-git-mounts")
async def refresh_profile_git_mounts(
profile_id: str,
confirm_destructive_refresh: bool = False,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Destructively refresh profile Git working copies used by instances."""
if not confirm_destructive_refresh:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Confirm destructive Git refresh before replacing local edits",
)
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Import lazily: instance_service imports tool schemas that transitively
# load API routers, so importing it during router initialization cycles.
from src.services.tool.instance_service import resolve_git_mounts
outcomes = await _running_profile_outcomes(session, profile.id)
for outcome in outcomes:
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
if (
instance is None
or not instance.compose_path
or instance.selected_config_profile_id is None
):
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
await resolve_git_mounts(
session,
resolved,
os.path.dirname(instance.compose_path),
)
outcome["status"] = "refreshed"
outcome["reason"] = "Canonical Git mount source refreshed in place"
return {"refresh_outcomes": outcomes}
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -161,6 +273,16 @@ async def delete_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
outcomes = await _running_profile_outcomes(session, profile.id)
if outcomes:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Profile is still used by running instances",
"outcomes": outcomes,
},
)
await session.delete(profile)
await session.commit()
+60 -5
View File
@@ -3,6 +3,8 @@
import asyncio
import json
import logging
from asyncio import QueueFull
from json import JSONDecodeError
import uuid
from contextlib import suppress
@@ -31,6 +33,9 @@ from src.services.terminal.terminal_manager import (
router = APIRouter()
logger = logging.getLogger(__name__)
MAX_PENDING_INPUT_MESSAGES = 64
MAX_TERMINAL_INPUT_BYTES = 1024 * 1024
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
@@ -320,8 +325,38 @@ async def _handle_terminal_websocket(
)
async def _input_write_loop(input_queue: asyncio.Queue[tuple[object, bytes]]) -> None:
"""Serialize PTY writes without blocking terminal control messages."""
while True:
session, data = await input_queue.get()
try:
await session.write_input(data) # type: ignore[attr-defined]
except Exception:
logger.debug("Terminal input write failed", exc_info=True)
finally:
input_queue.task_done()
def _queue_terminal_input(
input_queue: asyncio.Queue[tuple[object, bytes]], session: object, data: bytes
) -> bool:
"""Queue bounded terminal input without blocking control-message processing."""
if len(data) > MAX_TERMINAL_INPUT_BYTES:
return False
try:
input_queue.put_nowait((session, data))
except QueueFull:
return False
return True
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
"""Receive terminal messages while a dedicated worker serializes PTY input."""
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(
maxsize=MAX_PENDING_INPUT_MESSAGES
)
input_writer = asyncio.create_task(_input_write_loop(input_queue))
try:
while True:
session = session_ref.session
@@ -331,7 +366,12 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
if not _queue_terminal_input(
input_queue, session, message["bytes"]
):
logger.warning("Terminal input buffer exceeded for %s", instance_id)
await websocket.close(code=1009, reason="Terminal input buffer full")
break
elif "text" in message:
text = message["text"]
# A text frame that parses to a JSON object with a
@@ -345,13 +385,22 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
if text.startswith("{"):
try:
parsed = json.loads(text)
except json.JSONDecodeError:
except JSONDecodeError:
parsed = None
if isinstance(parsed, dict) and "type" in parsed:
ctrl = parsed
if ctrl is None:
await session.write_input(text.encode("utf-8"))
if not _queue_terminal_input(
input_queue, session, text.encode("utf-8")
):
logger.warning(
"Terminal input buffer exceeded for %s", instance_id
)
await websocket.close(
code=1009, reason="Terminal input buffer full"
)
break
continue
msg_type = ctrl["type"]
@@ -403,8 +452,14 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
continue
elif message["type"] == "websocket.disconnect":
break
except Exception:
except WebSocketDisconnect:
pass
except (RuntimeError, TypeError, ValueError) as exc:
logger.debug("Terminal WebSocket receive loop ended: %s", exc)
finally:
input_writer.cancel()
with suppress(asyncio.CancelledError):
await input_writer
async def _heartbeat_loop(websocket: WebSocket) -> None:
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
return v
class ConfigProfileRefreshOutcome(BaseModel):
instance_id: str
status: str
reason: str | None = None
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
includes: list[dict]
created_at: str
updated_at: str
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
class DefaultProfilesUpdate(BaseModel):
+118 -82
View File
@@ -5,10 +5,125 @@ import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
from src.models import ToolDefinitionManifest, ToolType
logger = logging.getLogger(__name__)
# All supported built-in tools run their long-lived process with these IDs.
# Canonical writable Config Profile mounts can therefore be shared without
# per-instance ownership changes.
BUILTIN_USER_UID = 1000
BUILTIN_USER_GID = 1000
BUILTIN_TOOL_TYPES = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: 'no'""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
- NB_UID=1000
- NB_GID=1000
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /home/node/{{WORKSPACE_NAME}}
volumes:
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
ports:
- "3000:3000"
command: >
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
npm install -g opencode-ai &&
exec setpriv --reuid=node --regid=node --init-groups opencode server"
stdin_open: true
tty: true
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
"""Set the built-in manifest user to the shared UID/GID in place.
The helper deliberately recognizes only Headquarter's conventional
``user`` account so it cannot rewrite a future custom tool definition.
"""
user = manifest.get("user")
if not isinstance(user, dict) or user.get("name") != "user":
return False
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
if changed:
user["uid"] = BUILTIN_USER_UID
user["gid"] = BUILTIN_USER_GID
return changed
async def _standardize_pi_agent_manifest(session) -> None:
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
manifest_definition = await session.scalar(
select(ToolDefinitionManifest).where(
ToolDefinitionManifest.name == "pi-agent",
ToolDefinitionManifest.created_by_id.is_(None),
)
)
if manifest_definition is None:
return
manifest = dict(manifest_definition.manifest)
if _standardize_builtin_manifest_user(manifest):
manifest_definition.manifest = manifest
logger.info("Standardized built-in Pi Agent user to 1000:1000")
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
@@ -45,88 +160,9 @@ async def seed_builtin_tool_types():
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: 'no'""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /home/user/{{WORKSPACE_NAME}}
volumes:
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
await _standardize_pi_agent_manifest(session)
for tool_data in builtin_types:
for tool_data in BUILTIN_TOOL_TYPES:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
@@ -6,6 +6,7 @@ and cycle protection.
import logging
import os
import tempfile
import uuid
from dataclasses import dataclass, field
from typing import Any
@@ -481,6 +482,7 @@ def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
working_dir: str | None = None,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
@@ -489,6 +491,8 @@ def apply_resolved_profile(
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
home_dir: Container home directory used for path expansion.
working_dir: Container working directory for top-level profile files.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
@@ -501,49 +505,71 @@ def apply_resolved_profile(
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
working_dir = working_dir or home_dir
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
logger.warning(
"Profile file path escapes instance directory: %s", file_path
)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Profile content belongs to the profile, not an individual tool instance.
# Keeping it beside the instance root gives every compatible instance the
# same host source while retaining the existing instance storage setting.
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts"
# Stage mount directories and prepare directory-level volume mounts.
# Each ResolvedMount targets a container directory; we stage all of its
# files under a single host directory and bind-mount that directory. This
# keeps the target directory writable by the container user, instead of
# having Docker create a root-owned parent directory when only individual
# files are mounted.
def write_canonical_file(
root: Path,
relative_path: str,
content: str,
*,
preserve_inode: bool = False,
) -> Path | None:
path = root / relative_path
try:
path.resolve().relative_to(root.resolve())
except ValueError:
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
return None
path.parent.mkdir(parents=True, exist_ok=True)
if preserve_inode and path.is_file():
# A file bind mount follows its inode, not its directory entry.
# Replacing this path would leave a running container attached to
# the old inode, so overwrite the existing file in place.
path.write_text(content, encoding="utf-8")
return path
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as temporary_file:
temporary_file.write(content)
temporary_path = Path(temporary_file.name)
temporary_path.replace(path)
return path
# Top-level profile files are individual bind mounts under the working
# directory. They therefore cannot mask the workspace directory itself.
for file_path, content in resolved.files.items():
canonical_file = write_canonical_file(
files_dir, file_path, content, preserve_inode=True
)
if canonical_file is None:
continue
volume_mounts.append(
{
"source": str(canonical_file),
"target": os.path.normpath(os.path.join(working_dir, file_path)),
"type": "bind",
"readonly": False,
}
)
# Explicit profile mounts remain directory-level bind mounts, but use the
# same profile-scoped canonical source for every instance.
for mount in resolved.mounts.values():
if not mount.files:
continue
expanded_target = os.path.normpath(
expand_container_path(mount.target, home_dir)
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir.mkdir(parents=True, exist_ok=True)
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
try:
full_path.resolve().relative_to(mount_dir.resolve())
except ValueError:
logger.warning("Mount file path escapes mount directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
write_canonical_file(mount_dir, file_path, content, preserve_inode=True)
volume_mounts.append(
{
@@ -554,7 +580,9 @@ def apply_resolved_profile(
}
)
return env_vars, files, volume_mounts, resolved.runtime_hints
# Files are now mounted directly from canonical storage, not copied into
# the instance directory for write_config_files().
return env_vars, {}, volume_mounts, resolved.runtime_hints
def expand_container_path(path: str, home_dir: str) -> str:
+234 -94
View File
@@ -2,7 +2,9 @@
import asyncio
import contextlib
import fcntl
import glob as glob_module
import hashlib
import logging
import os
import re
@@ -125,15 +127,20 @@ def _chown_staged_mounts(
uid: int,
gid: int,
) -> None:
"""Recursively chown staged mount sources to the container user.
"""Recursively chown writable profile and instance mount sources.
Config-profile mounts, git mounts, and SSH key mounts are staged under
instance_dir by the API process (root). Without this, the container
user cannot write into bind-mounted directories such as ~/.config.
Canonical non-Git profile sources are shared by compatible instances, so
they must be writable by the container user rather than copied per
instance. Instance-local composites and SSH mounts remain supported.
"""
canonical_profile_root = os.path.join(
os.path.dirname(instance_dir), "config-profiles"
)
for vol in extra_volumes:
source = vol.get("source", "")
if not source or not source.startswith(instance_dir):
if not source or not (
source.startswith(instance_dir) or source.startswith(canonical_profile_root)
):
continue
_chown_path(source, uid, gid)
@@ -154,61 +161,118 @@ def _relative_under(parent: str, child: str) -> str | None:
return None
def _stage_profile_mounts(profile_mounts: list[dict], instance_dir: str) -> list[dict]:
"""Copy profile bind sources into an instance-local, writable staging area."""
staged_mounts: list[dict] = []
staging_root = os.path.join(instance_dir, "mounts", "profiles")
for mount in profile_mounts:
source = mount.get("source", "")
target = mount.get("target", "")
if not source or not target:
continue
if not os.path.exists(source):
logger.warning("Skipping missing config profile mount source: %s", source)
continue
digest = hashlib.sha256(f"{source}\0{target}".encode()).hexdigest()[:16]
staged_source = os.path.join(staging_root, digest)
try:
if os.path.lexists(staged_source):
if os.path.isdir(staged_source):
shutil.rmtree(staged_source)
else:
os.unlink(staged_source)
os.makedirs(os.path.dirname(staged_source), exist_ok=True)
if os.path.isdir(source):
shutil.copytree(source, staged_source, symlinks=True)
else:
shutil.copy2(source, staged_source, follow_symlinks=False)
except OSError as exc:
logger.error("Failed to stage config profile mount %s: %s", source, exc)
continue
staged_mount = dict(mount)
staged_mount["source"] = staged_source
staged_mounts.append(staged_mount)
return staged_mounts
def _mounts_overlap(first_target: str, second_target: str) -> bool:
"""Return whether two normalized container mount targets intersect."""
return (
_relative_under(first_target, second_target) is not None
or _relative_under(second_target, first_target) is not None
)
def _copy_mount_source(source: str, destination: str) -> None:
"""Copy a bind-mount source into its destination in a composite tree."""
try:
if os.path.isdir(source):
os.makedirs(destination, exist_ok=True)
for entry in os.listdir(source):
source_entry = os.path.join(source, entry)
destination_entry = os.path.join(destination, entry)
if os.path.isdir(source_entry):
shutil.copytree(
source_entry,
destination_entry,
dirs_exist_ok=True,
symlinks=True,
)
else:
os.makedirs(os.path.dirname(destination_entry), exist_ok=True)
shutil.copy2(source_entry, destination_entry, follow_symlinks=False)
return
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy2(source, destination, follow_symlinks=False)
except OSError as exc:
raise RuntimeError(f"Unable to compose mount source {source}: {exc}") from exc
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
instance_dir: str,
) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources.
"""Overlay canonical profile files on Git directories without snapshots.
When a config profile mounts static files to the same directory as a
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
would mask the cloned repository. Instead, copy the profile files into
the git-mount source directory so the container sees both sets of files
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
An overlapping profile directory is expanded into individual child-file
mounts. Docker then mounts the Git working directory first and the more
specific canonical profile files last, preserving shared writable sources
instead of constructing an instance-local composite copy.
"""
remaining: list[dict] = []
for pvol in profile_mounts:
p_source = pvol.get("source", "")
p_target = pvol.get("target", "")
if not p_source or not os.path.exists(p_source):
remaining.append(pvol)
del instance_dir
result: list[dict] = list(git_mount_volumes)
for profile_mount in profile_mounts:
source = profile_mount.get("source", "")
target = profile_mount.get("target", "")
overlaps_git = any(
_mounts_overlap(target, git_mount.get("target", ""))
for git_mount in git_mount_volumes
)
if not overlaps_git or not os.path.isdir(source):
result.append(profile_mount)
continue
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
for root, _dirs, files in os.walk(source):
for filename in files:
file_source = os.path.join(root, filename)
relative_path = os.path.relpath(file_source, source)
result.append(
{
**profile_mount,
"source": file_source,
"target": os.path.join(target, relative_path),
}
)
rel = _relative_under(g_target, p_target)
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
if not merged:
remaining.append(pvol)
return remaining
return result
async def resolve_git_mounts(
@@ -227,12 +291,18 @@ async def resolve_git_mounts(
if not resolved.git_mounts:
return []
# Git mount sources are profile-scoped, not instance-scoped, so compatible
# instances bind the same canonical checkout.
clone_parent = os.path.join(
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
)
# Process all git mounts concurrently
tasks = []
for git_mount in resolved.git_mounts:
tasks.append(
resolve_single_git_mount(
session, git_mount, instance_dir, working_directory, home_dir
session, git_mount, clone_parent, working_directory, home_dir
)
)
@@ -265,6 +335,37 @@ def normalize_git_mount(entry: dict) -> dict:
return entry
@contextlib.contextmanager
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
"""Serialize clone and refresh operations for one canonical Git source."""
lock_dir = os.path.join(clone_parent, "git-mounts")
identity = f"{remote_url}:{branch or 'default'}"
lock_path = os.path.join(
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
)
try:
os.makedirs(lock_dir, exist_ok=True)
with open(lock_path, "a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError as exc:
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
def clone_git_repo_locked(
remote_url: str,
branch: str | None,
clone_parent: str,
project_name: str | None = None,
) -> str:
"""Clone or refresh a canonical source while holding its process lock."""
with _git_mount_lock(clone_parent, remote_url, branch):
return clone_git_repo(remote_url, branch, clone_parent, project_name)
def clone_git_repo(
remote_url: str,
branch: str | None,
@@ -283,16 +384,27 @@ def clone_git_repo(
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
repo_path = clone_repository(
remote_url,
None, # No SSH key for now - can be added later
clone_dir,
branch or "main",
project_name=project_name,
)
clone_name = _slugify_directory_name(project_name) if project_name else "repo-clone"
repo_path = os.path.join(clone_dir, clone_name)
if not os.path.exists(repo_path):
if os.path.isdir(os.path.join(repo_path, ".git")):
# Reuse the deterministic per-repository cache on repeated starts.
try:
pull_repository_updates(repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
else:
try:
# A failed clone can leave its destination behind. Remove only the
# computed clone path so the next start can retry cleanly.
if os.path.lexists(repo_path):
logger.warning("Removing incomplete git mount clone at %s", repo_path)
if os.path.isdir(repo_path) and not os.path.islink(repo_path):
shutil.rmtree(repo_path)
else:
os.unlink(repo_path)
os.makedirs(clone_dir, exist_ok=True)
repo_path = clone_repository(
remote_url,
@@ -305,13 +417,6 @@ def clone_git_repo(
except Exception as exc:
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
raise
else:
# Repo exists - pull latest updates
try:
pull_repository_updates(repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
# Handle branch checkout if specified
if branch and repo_path:
@@ -421,7 +526,7 @@ def resolve_git_mount_mappings(
async def resolve_single_git_mount(
session: AsyncSession,
git_mount: dict,
instance_dir: str | None = None,
clone_parent: str | None = None,
working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]:
@@ -443,15 +548,15 @@ async def resolve_single_git_mount(
logger.warning("Invalid git mount skipped: no mappings")
return []
if not instance_dir:
logger.warning("Git mount skipped: no instance_dir provided for cloning")
if not clone_parent:
logger.warning("Git mount skipped: no canonical profile directory provided")
return []
# Clone or pull the repository. Git mounts are auxiliary, so they keep
# using the repository URL basename rather than the project name.
try:
repo_path = await asyncio.to_thread(
clone_git_repo, remote_url, branch, instance_dir
clone_git_repo_locked, remote_url, branch, clone_parent
)
except Exception as exc:
logger.warning(
@@ -464,7 +569,14 @@ async def resolve_single_git_mount(
return []
# Resolve all mappings from the cloned repo
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
volumes = resolve_git_mount_mappings(
repo_path, mappings, working_directory, home_dir
)
# Profile-scoped Git working copies are writable and shared by compatible
# containers. Explicit refresh replaces local edits with the remote ref.
for volume in volumes:
volume["readonly"] = False
return volumes
def checkout_branch(repo_path: str, branch: str) -> bool:
@@ -508,10 +620,10 @@ def checkout_branch(repo_path: str, branch: str) -> bool:
def pull_repository_updates(repo_path: str, remote_url: str) -> None:
"""Pull latest updates from remote repository.
"""Replace a profile working copy with its current remote branch.
Used when starting a new container with an existing cloned repository
to ensure the latest code is mounted.
Git profile mounts are writable shared working copies. Refresh discards
local container/editor edits after fetching the remote baseline.
"""
import subprocess
@@ -525,15 +637,22 @@ def pull_repository_updates(repo_path: str, remote_url: str) -> None:
if result.returncode != 0:
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
# Pull changes for current branch
result = subprocess.run(
["git", "-C", repo_path, "pull", "origin"],
branch_result = subprocess.run(
["git", "-C", repo_path, "branch", "--show-current"],
capture_output=True,
text=True,
)
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ""
if not branch:
raise RuntimeError("Unable to determine Git working-copy branch")
result = subprocess.run(
["git", "-C", repo_path, "reset", "--hard", f"origin/{branch}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to pull updates: {result.stderr}")
raise RuntimeError(f"Failed to reset working copy: {result.stderr}")
def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
@@ -1398,17 +1517,22 @@ async def start_tool_instance(
resolved = await resolve_profile(
session, instance.selected_config_profile_id
)
# Profile working-directory hints determine where individual
# canonical profile files are bind-mounted at container creation.
profile_hints = resolved.runtime_hints
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved, home_dir)
apply_resolved_profile(
instance_dir, resolved, home_dir, working_directory
)
)
# Profile hints override the manifest/tool defaults, and git mounts
# need the final working directory to resolve relative target paths.
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env)
@@ -1416,22 +1540,24 @@ async def start_tool_instance(
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
# Non-Git profile mounts bind directly to canonical profile
# storage so edits made by one compatible container are visible to
# every other container and the profile editor readback path.
composed_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts,
git_mount_volumes,
instance_dir,
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes)
extra_volumes.extend(composed_mounts)
logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
"Applied config profile %s to instance %s (env=%d, files=%d, profile_mounts=%d, git_mounts=%d, composed_mounts=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
len(composed_mounts),
)
except ConfigProfileCycleError as exc:
logger.error(
@@ -1472,7 +1598,15 @@ async def start_tool_instance(
if ssh_keys_to_mount:
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
os.makedirs(ssh_dir, exist_ok=True)
try:
os.makedirs(ssh_dir, exist_ok=True)
except OSError as exc:
logger.error(
"Failed to create SSH mount directory for instance %s: %s",
instance.id,
exc,
)
ssh_keys_to_mount = []
key_filenames = []
for ssh_key in ssh_keys_to_mount:
@@ -2185,7 +2319,13 @@ async def delete_tool_instance(
if os.path.exists(instance_dir):
import shutil
shutil.rmtree(instance_dir)
try:
shutil.rmtree(instance_dir)
except OSError as exc:
logger.error(
"Failed to remove instance directory %s: %s", instance_dir, exc
)
raise RuntimeError("Failed to remove instance files") from exc
await publish_lifecycle_event(
event_bus=_event_bus,
+77 -2
View File
@@ -1,7 +1,15 @@
"""Integration tests for multi-session terminal WebSocket and REST API."""
import asyncio
import pytest
from fastapi.testclient import TestClient
from src.api.system.terminal import (
MAX_TERMINAL_INPUT_BYTES,
SessionRef,
_queue_terminal_input,
_write_loop,
)
from src.main import app
@@ -19,12 +27,79 @@ class TestTerminalWebSocketMultiSession:
# the route exists by checking for a 403 (no auth cookie)
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
# WebSocket endpoint returns 403 when accessed via HTTP GET
assert response.status_code in (403, 404)
assert response.status_code == 403 or response.status_code == 404
def test_default_session_alias_route_exists(self, client):
"""The default session alias route should still exist."""
response = client.get("/ws/tool-instances/test-instance/terminal")
assert response.status_code in (403, 404)
assert response.status_code == 403 or response.status_code == 404
def test_terminal_input_queue_rejects_excess_input_without_blocking() -> None:
"""A stalled PTY writer cannot make the input queue grow without limit."""
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(maxsize=1)
session = object()
assert _queue_terminal_input(input_queue, session, b"first")
assert not _queue_terminal_input(input_queue, session, b"second")
assert not _queue_terminal_input(
asyncio.Queue(), session, b"x" * (MAX_TERMINAL_INPUT_BYTES + 1)
)
@pytest.mark.asyncio
async def test_ack_is_processed_while_a_pty_write_is_waiting() -> None:
"""A blocked paste writer must not block flow-control acknowledgements."""
write_started = asyncio.Event()
class Session:
_closed = False
def __init__(self) -> None:
self.acks: list[int] = []
self.write_finished = False
def is_alive(self) -> bool:
return True
async def write_input(self, _data: bytes) -> None:
write_started.set()
try:
await asyncio.Event().wait()
finally:
self.write_finished = True
def acknowledge_data(self, char_count: int) -> None:
self.acks.append(char_count)
class WebSocket:
def __init__(self) -> None:
self.messages = iter(
[
{"type": "websocket.receive", "bytes": b"large paste"},
{"type": "websocket.receive", "text": '{"type":"ack","chars":4096}'},
{"type": "websocket.disconnect"},
]
)
self.receive_count = 0
async def receive(self):
self.receive_count += 1
if self.receive_count > 1:
await write_started.wait()
return next(self.messages)
session = Session()
websocket = WebSocket()
task = asyncio.create_task(_write_loop(SessionRef(session), websocket, "instance"))
await asyncio.wait_for(write_started.wait(), timeout=0.1)
await asyncio.sleep(0)
assert session.acks == [4096]
await task
assert session.write_finished
class TestTerminalRestApi:
@@ -0,0 +1,62 @@
"""Tests for the shared non-root user used by built-in tools."""
from pathlib import Path
from src.seeds.builtin_tool_types import (
BUILTIN_TOOL_TYPES,
BUILTIN_USER_GID,
BUILTIN_USER_UID,
_standardize_builtin_manifest_user,
)
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
"""Every legacy built-in Compose tool declares the shared UID/GID."""
templates = {
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
}
assert "- PUID=1000" in templates["code-server"]
assert "- PGID=1000" in templates["code-server"]
assert "- NB_UID=1000" in templates["jupyter-notebook"]
assert "- NB_GID=1000" in templates["jupyter-notebook"]
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
def test_only_builtin_user_manifest_is_standardized() -> None:
"""The startup migration cannot rewrite a future custom tool user."""
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
assert _standardize_builtin_manifest_user(builtin_manifest)
assert builtin_manifest["user"] == {
"name": "user",
"uid": BUILTIN_USER_UID,
"gid": BUILTIN_USER_GID,
}
assert not _standardize_builtin_manifest_user(custom_manifest)
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
def test_tool_image_templates_define_shared_ids() -> None:
"""Project-owned image templates explicitly create or map UID/GID 1000."""
root = Path(__file__).resolve().parents[4]
sources = {
name: (root / "tool-images" / name).read_text()
for name in (
"base.dockerfile",
"opencode.dockerfile",
"pi-agent.dockerfile",
"code-server.dockerfile",
"jupyter.dockerfile",
)
}
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
assert "groupadd -g 1000 user" in sources[name]
assert "useradd -m -u 1000 -g 1000" in sources[name]
assert "PUID=1000" in sources["code-server.dockerfile"]
assert "PGID=1000" in sources["code-server.dockerfile"]
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
@@ -0,0 +1,23 @@
"""Tests for Config Profile refresh safety contracts."""
import uuid
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from src.api.config.config_profiles import refresh_profile_git_mounts
@pytest.mark.unit
async def test_git_refresh_requires_destructive_confirmation() -> None:
"""The endpoint must not reset a writable working copy without consent."""
with pytest.raises(HTTPException) as exc_info:
await refresh_profile_git_mounts(
profile_id=str(uuid.uuid4()),
confirm_destructive_refresh=False,
current_user_id=uuid.uuid4(),
session=AsyncMock(),
)
assert exc_info.value.status_code == 409
@@ -36,7 +36,7 @@ class TestMergeFunctions:
def test_merge_env_vars_tracks_overrides(self) -> None:
"""Test that env var overrides are tracked."""
overrides = {}
overrides: dict[str, str] = {}
_merge_env_vars(
{"A": "1"},
{"A": "2"},
@@ -93,7 +93,7 @@ class TestMergeFunctions:
"""Test that mount mode conflicts are resolved (later wins)."""
from src.services.config.config_profile_resolver import ResolvedMount
overrides = {}
overrides: dict[str, str] = {}
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
[{"target": "/app", "mode": "ro", "files": {}}],
@@ -532,6 +532,107 @@ class TestApplyResolvedProfile:
assert Path(volumes[0]["source"]).name == "workspace_x_y"
assert (Path(volumes[0]["source"]) / "z.json").exists()
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
"""Top-level files are shared safely without mounting over a workspace."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={".tool/config.toml": "setting = true"},
)
instance_root = tmp_path / "instances"
_, files, volumes, _ = apply_resolved_profile(
str(instance_root / "instance-a"),
resolved,
working_dir="/workspace/project",
)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
)
assert files == {}
assert volumes == [
{
"source": str(canonical_file),
"target": "/workspace/project/.tool/config.toml",
"type": "bind",
"readonly": False,
}
]
assert canonical_file.read_text() == "setting = true"
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""An individually bind-mounted file must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={"settings.toml": "value = 1"},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_mounted_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""A file inside a profile directory mount must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={
"/etc/tool": ResolvedMount(
target="/etc/tool", mode="rw", files={"settings.toml": "value = 1"}
)
},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root
/ "config-profiles"
/ str(profile_id)
/ "mounts"
/ "etc_tool"
/ "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.mounts["/etc/tool"].files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
)
instance_root = tmp_path / "instances"
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
assert first_volumes[0]["source"] == second_volumes[0]["source"]
assert first_volumes[0]["source"] == str(
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
)
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
"""A mount with no files should not produce any volume entries."""
resolved = ResolvedProfile(
@@ -579,7 +680,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
assert volumes[0]["target"] == "/etc/app"
assert volumes[0].get("readonly") is True
assert volumes[0].get("readonly")
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'rw' should not set readonly on the volume entry."""
@@ -598,7 +699,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False
assert not volumes[0].get("readonly")
class TestCheckIncludeCycle:
+231 -121
View File
@@ -1,15 +1,21 @@
"""Unit tests for the tool instance service."""
import hashlib
import uuid
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock
import pytest
from src.services.tool.instance_service import (
_chown_staged_mounts,
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
_stage_profile_mounts,
clone_git_repo,
modify_compose_file,
prepare_manifest_instance,
pull_repository_updates,
)
@@ -68,154 +74,258 @@ class TestGetRepositoryMountName:
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts."""
class TestCloneGitRepo:
"""Regression tests for reusable config-profile git mount clones."""
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
"""When a profile mount targets the same directory as a git mount,
the profile files should be copied into the git-mount source so the
container sees both sets of files through one bind mount."""
def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles"
branch = None
clone_parent = str(tmp_path)
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
(repo_path / ".git").mkdir(parents=True)
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
pull = MagicMock()
monkeypatch.setattr(instance_service, "clone_repository", clone)
monkeypatch.setattr(instance_service, "pull_repository_updates", pull)
result = clone_git_repo(remote_url, branch, clone_parent)
assert result == str(repo_path)
clone.assert_not_called()
pull.assert_called_once_with(str(repo_path), remote_url)
def test_refresh_resets_writable_working_copy_to_remote_branch(
self, monkeypatch
) -> None:
from src.services.tool import instance_service
results = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
]
run = MagicMock(side_effect=results)
monkeypatch.setattr(instance_service.subprocess, "run", run)
pull_repository_updates("/work/profile-git", "https://example.test/repo.git")
assert run.call_args_list[0].args[0] == [
"git",
"-C",
"/work/profile-git",
"fetch",
"origin",
]
assert run.call_args_list[2].args[0] == [
"git",
"-C",
"/work/profile-git",
"reset",
"--hard",
"origin/main",
]
def test_replaces_incomplete_clone_before_retry(
self, monkeypatch, tmp_path
) -> None:
from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles"
url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
repo_path = clone_dir / "repo-clone"
repo_path.mkdir(parents=True)
(repo_path / "partial-file").write_text("incomplete")
def clone(_url, _key, destination, _branch, project_name=None):
assert destination == str(clone_dir)
assert project_name is None
assert not repo_path.exists()
(repo_path / ".git").mkdir(parents=True)
return str(repo_path)
monkeypatch.setattr(instance_service, "clone_repository", clone)
result = clone_git_repo(remote_url, "main", str(tmp_path))
assert result == str(repo_path)
assert (repo_path / ".git").is_dir()
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for composing profile and Git mounts without Docker masking."""
def test_stages_profile_source_under_instance_directory(self, tmp_path) -> None:
"""Profile sources must be instance-local before ownership is fixed."""
instance_dir = tmp_path / "instance"
profile_source = tmp_path / "profile" / "settings.json"
profile_source.parent.mkdir(parents=True)
profile_source.write_text("{}")
staged = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi/settings.json"}],
str(instance_dir),
)
assert staged[0]["source"].startswith(str(instance_dir))
assert staged[0]["source"] != str(profile_source)
assert Path(staged[0]["source"]).read_text() == "{}"
def test_staged_profile_source_is_chowned_for_container_user(
self, monkeypatch, tmp_path
) -> None:
"""The ownership pass must include the instance-local profile copy."""
from src.services.tool import instance_service
instance_dir = tmp_path / "instance"
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
staged = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi"}],
str(instance_dir),
)
chown = MagicMock()
monkeypatch.setattr(instance_service, "_chown_path", chown)
_chown_staged_mounts(staged, str(instance_dir), 1000, 1000)
chown.assert_called_once_with(staged[0]["source"], 1000, 1000)
def test_exact_overlap_creates_instance_local_composite(self, tmp_path) -> None:
"""Profile files extend a Git root without mutating its shared clone."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile" / "home_user_.pi"
profile_source.mkdir(parents=True)
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
"readonly": False,
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
],
str(instance_dir),
)
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
)
assert result == []
assert (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}"
assert len(result) == 2
assert result[0]["source"] == str(git_source)
assert result[0]["target"] == "/home/user/.pi"
assert result[1]["source"].endswith("/settings.json")
assert result[1]["target"] == "/home/user/.pi/settings.json"
assert not (git_source / "settings.json").exists()
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the
corresponding subdirectory of the git-mount source."""
def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
"""Nested targets are composed at the Git root, preserving siblings."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile" / "agent"
profile_source.mkdir(parents=True)
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("x")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/agent",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi/agent"}],
str(instance_dir),
)
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
)
assert result == []
assert (git_source / "agent" / "settings.json").read_text() == "x"
assert (git_source / "README").read_text() == "repo"
assert len(result) == 2
assert result[0]["source"] == str(git_source)
assert result[1]["target"] == "/home/user/.pi/agent/settings.json"
assert not (git_source / "agent").exists()
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
"""Profile mounts that do not overlap a git mount are returned as-is."""
def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
"""A file bind mount is composed into the Git directory, not masked."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config").write_text("c")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.config",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
"""If the overlapping git-mount source is a file, the profile mount
cannot be merged and must be kept."""
git_source = tmp_path / "file.txt"
git_source.write_text("file")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
]
git_mount_volumes = [
{
"source": str(git_source),
"target": "/home/user/.pi/file.txt",
"type": "bind",
}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the
git-mount source directory."""
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "settings.json"
profile_source.write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
}
],
str(instance_dir),
)
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
)
assert result == []
assert (git_source / "settings.json").read_text() == "{}"
assert len(result) == 2
assert result[0]["source"] == str(git_source)
assert result[1]["target"] == "/home/user/.pi/settings.json"
def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
"""A profile parent mount keeps Git content and its own sibling files."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "plugin.toml").write_text("git")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config.toml").write_text("profile")
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user"}], str(instance_dir)
)
result = _stack_profile_mounts_with_git_mounts(
profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
)
assert len(result) == 2
assert result[0]["source"] == str(git_source)
assert result[1]["target"] == "/home/user/config.toml"
def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
"""Unrelated profile and Git mounts retain their independent sources."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.config"}],
str(instance_dir),
)
git_mounts = [{"source": str(git_source), "target": "/home/user/.pi"}]
assert (
_stack_profile_mounts_with_git_mounts(
profile_mounts, git_mounts, str(instance_dir)
)
== git_mounts + profile_mounts
)
@pytest.mark.unit
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#275d4b" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Headquarter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Headquarter</title>
<rect width="64" height="64" rx="15" fill="#275d4b"/>
<path fill="#fffef9" d="M17 15h8v13h14V15h8v34h-8V36H25v13h-8z"/>
<path fill="#9dcdb7" d="M25 28h14v8H25z"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockPost = vi.fn();
vi.mock("./client", () => ({
apiClient: {
post: (...args: unknown[]) => mockPost(...args),
},
}));
import { refreshConfigProfileGitMounts } from "./config-profiles";
describe("refreshConfigProfileGitMounts", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("confirms the destructive refresh at the API boundary", async () => {
mockPost.mockResolvedValue({ data: { refresh_outcomes: [] } });
await expect(refreshConfigProfileGitMounts("profile-1")).resolves.toEqual({
refresh_outcomes: [],
});
expect(mockPost).toHaveBeenCalledWith(
"/config-profiles/profile-1/refresh-git-mounts",
undefined,
{ params: { confirm_destructive_refresh: true } },
);
});
});
+22
View File
@@ -1,5 +1,15 @@
import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome {
instance_id: string;
status:
| "compatible"
| "refreshed"
| "restart_required"
| "incompatible_permissions";
reason?: string;
}
export interface ConfigProfile {
id: string;
user_id: string;
@@ -16,6 +26,7 @@ export interface ConfigProfile {
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
refresh_outcomes?: ConfigProfileRefreshOutcome[];
}
export interface ConfigProfileMount {
@@ -138,6 +149,17 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`);
};
export const refreshConfigProfileGitMounts = async (
id: string,
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
const response = await apiClient.post<{
refresh_outcomes: ConfigProfileRefreshOutcome[];
}>(`/config-profiles/${id}/refresh-git-mounts`, undefined, {
params: { confirm_destructive_refresh: true },
});
return response.data;
};
export const updateProfileIncludes = async (
id: string,
data: UpdateIncludesRequest,
@@ -14,6 +14,8 @@ interface Props {
saveStatus: "idle" | "saving" | "saved" | "error";
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
projects: ProjectWithRepos[];
toolTypes: ToolType[];
availableProfiles: ConfigProfile[];
@@ -23,6 +25,8 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void;
@@ -54,6 +58,8 @@ export const ConfigProfileEditorPanel = ({
saveStatus,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
projects,
toolTypes,
availableProfiles,
@@ -63,6 +69,8 @@ export const ConfigProfileEditorPanel = ({
onSubmit,
onReset,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onAddInclude,
onRemoveInclude,
onDragStart,
@@ -114,6 +122,20 @@ export const ConfigProfileEditorPanel = ({
</div>
{!isCreating && selectedProfile && (
<div className="row row-sm">
<button className="btn btn-secondary" onClick={onReloadWorkingCopy} disabled={isReloadingWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button className="btn btn-secondary" onClick={onRefreshGitMounts} disabled={isRefreshingGitMounts}>
{isRefreshingGitMounts ? (
<>
<Icon name="loading" size="sm" /> Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</>
@@ -28,6 +28,8 @@ interface Props {
availableProfiles: ConfigProfile[];
previewData: ResolvedProfile | null;
previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
saveStatus: "idle" | "saving" | "saved" | "error";
error: string | null;
onViewChange: (view: MobileView) => void;
@@ -64,6 +66,8 @@ interface Props {
) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void;
}
@@ -79,6 +83,8 @@ export const ConfigProfilesMobileView = ({
availableProfiles,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
saveStatus,
error,
onViewChange,
@@ -104,6 +110,8 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile,
onRemoveMountFile,
onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onClosePreview,
}: Props) => {
const [isSaving, setIsSaving] = useState(false);
@@ -363,6 +371,26 @@ export const ConfigProfilesMobileView = ({
onDelete={handleDeleteClick}
>
<div className="mobile-detail-actions-extra">
<button type="button" className="secondary-button" disabled={isReloadingWorkingCopy} onClick={onReloadWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button
type="button"
className="secondary-button"
disabled={isRefreshingGitMounts}
onClick={() => onRefreshGitMounts(selectedProfile.id)}
>
{isRefreshingGitMounts ? (
<>
<Icon name="loading" size="sm" />
Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button
type="button"
className="secondary-button"
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
shouldShowTerminalCopyMenu,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
expect(getTerminalScrollbackLimit(true)).toBe(10_000);
});
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
expect(getTerminalScrollbackLimit(false)).toBe(0);
});
it("rejects stale WebSocket callbacks after a replacement connection", () => {
const current = {} as WebSocket;
const stale = {} as WebSocket;
expect(isCurrentWebSocket(current, current)).toBe(true);
expect(isCurrentWebSocket(current, stale)).toBe(false);
});
it("retries a heartbeat timeout but not a server socket replacement", () => {
expect(shouldRetryWebSocketClose(4000, "Heartbeat timeout")).toBe(true);
expect(shouldRetryWebSocketClose(4000, "New connection established")).toBe(
false,
);
});
});
describe("shouldShowTerminalCopyMenu", () => {
it("shows Copy for selected terminal output", () => {
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
});
it("keeps the native context menu when no output is selected", () => {
expect(shouldShowTerminalCopyMenu("")).toBe(false);
});
});
@@ -53,6 +53,37 @@ const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
export function getTerminalScrollbackLimit(isMobile: boolean): number {
return isMobile ? 10_000 : 0;
}
export function isCurrentWebSocket(
current: WebSocket | null,
candidate: WebSocket,
): boolean {
return current === candidate;
}
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
export function shouldShowTerminalCopyMenu(selection: string): boolean {
return selection.length > 0;
}
function copyTextWithFallback(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -83,6 +114,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const bracketedPasteEnabledRef = useRef(false);
const pasteTextRef = useRef<(text: string) => void>(() => {});
const reconnectAttemptsRef = useRef(0);
const reconnectTimerRef = useRef<number | null>(null);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
@@ -91,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [copyMenu, setCopyMenu] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
@@ -111,7 +148,23 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return fontSize;
}, [fontSize]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const connectWebSocket = useCallback(() => {
const currentWs = wsRef.current;
if (
currentWs?.readyState === WebSocket.CONNECTING ||
currentWs?.readyState === WebSocket.OPEN
) {
return currentWs;
}
clearReconnectTimer();
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
@@ -161,6 +214,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onopen = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) {
ws.close(1000, "Superseded connection");
return;
}
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
@@ -201,7 +259,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
@@ -260,6 +318,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onclose = (event) => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
wsRef.current = null;
if (ackTimeout) window.clearTimeout(ackTimeout);
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -275,18 +337,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return;
}
if (event.code === 1000) {
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
// Transient errors: attempt reconnection.
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
@@ -295,11 +351,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const delay =
RECONNECT_DELAY_BASE *
Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
clearReconnectTimer();
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null;
if (
!isUnmountingRef.current &&
document.visibilityState !== "hidden" &&
wsRef.current === null
) {
connectWebSocket();
}
}, delay);
@@ -307,15 +366,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onerror = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId, sessionId]);
}, [clearReconnectTimer, instanceId, sessionId]);
useEffect(() => {
if (!terminalRef.current) return;
isUnmountingRef.current = false;
permanentErrorRef.current = null;
// Initialize terminal
const currentFontSize = calculateFontSize();
@@ -326,14 +388,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
lineHeight: 1.2,
letterSpacing: 0,
allowTransparency: false,
// This terminal only ever hosts full-screen TUI tools (pi-agent,
// opencode), which repaint in place in the normal buffer and do not
// use the alternate screen or mouse tracking. With scrollback, every
// repaint accumulates as history → a viewport scrollbar appears and
// the mouse-wheel scrolls through stale frames instead of the app.
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
scrollback: 0,
// Desktop tools repaint in place, so retaining their normal buffer
// creates stale frames that native wheel scrolling can revisit. Mobile
// instead uses its custom touch handler to scroll normal-buffer output,
// which requires retained history.
scrollback: getTerminalScrollbackLimit(isMobile),
ignoreBracketedPasteMode: false,
fastScrollSensitivity: 0,
scrollSensitivity: 0,
@@ -409,7 +468,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
connectWebSocket();
pasteTextRef.current = (text: string) => {
const currentWs = wsRef.current;
@@ -424,8 +483,27 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.paste(text);
};
const handleBrowserCopy = (event: ClipboardEvent) => {
const selection = term.getSelection();
if (!selection) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", selection);
};
const handleTerminalContextMenu = (event: MouseEvent) => {
const selection = term.getSelection();
if (!shouldShowTerminalCopyMenu(selection)) {
setCopyMenu(null);
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
};
const handleBrowserPaste = (event: ClipboardEvent) => {
if (!bracketedPasteEnabledRef.current) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
return;
@@ -435,6 +513,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll.
@@ -657,14 +737,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Visibility API for reconnection
const handleVisibilityChange = () => {
const currentWs = wsRef.current;
if (
document.visibilityState === "visible" &&
ws &&
ws.readyState !== WebSocket.OPEN
!permanentErrorRef.current &&
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
@@ -673,6 +751,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return () => {
isUnmountingRef.current = true;
clearReconnectTimer();
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
@@ -683,11 +762,19 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener(
"contextmenu",
handleTerminalContextMenu,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
if (ws) {
ws.close(1000, "Component unmounting");
const currentWs = wsRef.current;
wsRef.current = null;
if (currentWs) {
currentWs.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -699,7 +786,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Ignore disposal errors from partially torn-down terminal
}
};
}, [instanceId, connectWebSocket]);
}, [instanceId, connectWebSocket, isMobile]);
useImperativeHandle(ref, () => ({
fit: () => {
@@ -815,6 +902,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
setCopyMenu(null);
if (isMobile && termRef.current) {
termRef.current.focus();
}
@@ -929,6 +1017,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
</div>
</div>
)}
{copyMenu && (
<button
aria-label="Copy selected terminal text"
className="terminal-context-copy"
onMouseDown={(event) => {
event.preventDefault();
copyTextWithFallback(copyMenu.text);
setCopyMenu(null);
}}
style={{
left: copyMenu.x,
position: "fixed",
top: copyMenu.y,
zIndex: 1000,
}}
type="button"
>
Copy
</button>
)}
{error && (
<div className="terminal-error">
{error}
+102 -14
View File
@@ -5,6 +5,7 @@ import {
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
refreshConfigProfileGitMounts,
updateConfigProfile,
updateProfileIncludes,
type ConfigProfile,
@@ -34,17 +35,23 @@ export const useConfigProfiles = () => {
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
const [isReloadingWorkingCopy, setIsReloadingWorkingCopy] = useState(false);
const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const selectedProfile =
profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
@@ -89,7 +96,9 @@ export const useConfigProfiles = () => {
is_default: profile.is_default,
});
setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
profile.includes.map(
(inc: { included_profile_id: string }) => inc.included_profile_id,
),
);
setError(null);
setSaveStatus("idle");
@@ -208,20 +217,39 @@ export const useConfigProfiles = () => {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
await updateProfileIncludes(newProfile.id, {
includes: includedProfileIds,
});
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
const refreshed = (await listConfigProfiles()).find(
(p) => p.id === newProfile.id,
);
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
const updatedProfile = await updateConfigProfile(
selectedProfile.id,
formData,
);
await updateProfileIncludes(selectedProfile.id, {
includes: includedProfileIds,
});
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
(outcome) => outcome.status === "restart_required",
);
if (restartOutcomes?.length) {
setError(
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
);
}
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
const refreshed = (await listConfigProfiles()).find(
(p) => p.id === selectedProfile.id,
);
if (refreshed) populateForm(refreshed);
}
return true;
@@ -233,7 +261,8 @@ export const useConfigProfiles = () => {
};
const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
if (!window.confirm("Are you sure you want to delete this config profile?"))
return;
try {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
@@ -242,8 +271,57 @@ export const useConfigProfiles = () => {
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
} catch (err) {
setError(extractErrorMessage(err));
}
};
const handleReloadWorkingCopy = async (): Promise<void> => {
if (!selectedProfileId || isReloadingWorkingCopy) return;
setError(null);
setIsReloadingWorkingCopy(true);
try {
const refreshedProfiles = await listConfigProfiles();
setProfiles(refreshedProfiles);
const refreshedProfile = refreshedProfiles.find(
(profile) => profile.id === selectedProfileId,
);
if (refreshedProfile) populateForm(refreshedProfile);
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setIsReloadingWorkingCopy(false);
}
};
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
if (isRefreshingGitMounts) return false;
if (
!window.confirm(
"Refresh Git mounts? This replaces local container and editor edits with the configured remote branch.",
)
)
return false;
setError(null);
setIsRefreshingGitMounts(true);
try {
const result = await refreshConfigProfileGitMounts(id);
const refreshed = result.refresh_outcomes.filter(
(outcome) => outcome.status === "refreshed",
);
setError(
refreshed.length
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
: "No running instances currently use this profile's Git mounts.",
);
return true;
} catch (err) {
setError(extractErrorMessage(err));
return false;
} finally {
setIsRefreshingGitMounts(false);
}
};
@@ -268,7 +346,10 @@ export const useConfigProfiles = () => {
};
const addEnvVar = () => {
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle");
};
@@ -323,7 +404,10 @@ export const useConfigProfiles = () => {
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
const updateMount = (
index: number,
updates: Partial<ConfigProfile["mounts"][0]>,
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
@@ -393,6 +477,8 @@ export const useConfigProfiles = () => {
error,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData,
includedProfileIds,
dragOverIndex,
@@ -402,6 +488,8 @@ export const useConfigProfiles = () => {
handleSubmit,
handleDelete,
handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
updateEnvVar,
+14
View File
@@ -23,6 +23,8 @@ export const ConfigProfilesPage = () => {
error,
previewData,
previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData,
includedProfileIds,
dragOverIndex,
@@ -32,6 +34,8 @@ export const ConfigProfilesPage = () => {
handleSubmit,
handleDelete,
handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
updateEnvVar,
@@ -110,6 +114,8 @@ export const ConfigProfilesPage = () => {
availableProfiles={availableProfilesForInclude()}
previewData={previewData}
previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
saveStatus={saveStatus}
error={error}
onViewChange={setMobileView}
@@ -135,6 +141,8 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile}
onPreview={handlePreview}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)}
/>
);
@@ -167,6 +175,8 @@ export const ConfigProfilesPage = () => {
saveStatus={saveStatus}
previewData={previewData}
previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
projects={projects}
toolTypes={toolTypes}
availableProfiles={availableProfilesForInclude()}
@@ -176,6 +186,10 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit}
onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={() =>
selectedProfile && handleRefreshGitMounts(selectedProfile.id)
}
onAddInclude={addInclude}
onRemoveInclude={removeInclude}
onDragStart={handleDragStart}
+3
View File
@@ -10,6 +10,9 @@ map: openspec/.pi-map.md
- openspec/changes/archive
index: openspec/changes/archive/.pi-map.index.md
map: openspec/changes/archive/.pi-map.md
- openspec/changes/fix-config-profile-git-mount-clone-reuse
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
- openspec/changes/fix-container-status-false-positive
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
@@ -0,0 +1,21 @@
# Add a Headquarter Favicon
## Summary
Add a compact, recognizable favicon for Headquarter and register it in the web document head.
## Design
Use a geometric cream `H` on the product's evergreen brand field. The mark remains identifiable at small browser-tab sizes, avoids font rendering dependencies, and matches both light and dark application themes.
## Scope
- `apps/web/public/favicon.svg`
- `apps/web/index.html`
## Acceptance Criteria
- [ ] The browser uses a dedicated Headquarter favicon.
- [ ] The mark remains legible at small sizes and on light or dark browser chrome.
- [ ] The HTML declares the icon type and a matching browser theme color.
- [ ] Frontend production build passes.
@@ -0,0 +1,6 @@
# Add a Headquarter Favicon — Tasks
- [x] Create the favicon asset.
- [x] Register the favicon and browser theme color in the web entry document.
- [x] Run the frontend production build.
- [x] Update project maps for changed source files.
@@ -0,0 +1,20 @@
# openspec/changes/fix-config-profile-git-mount-clone-reuse (index)
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
## role
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
## parent
index: openspec/changes/.pi-map.index.md
map: openspec/changes/.pi-map.md
## children
-
## files
- change.md
- tasks.md
## links
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,20 @@
# openspec/changes/fix-config-profile-git-mount-clone-reuse
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
## role
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
## files
- change.md | Describes the cached clone regression, required behavior, implementation scope, and verification plan.
- tasks.md | Tracks investigation, regression testing, implementation, project-map maintenance, verification, and commit status.
## arch
Documentation-only OpenSpec change package with separate change rationale and implementation task checklist.
## tags
config, profile, git, mount, clone, cache, startup, fix
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,27 @@
# Fix config profile git mount clone reuse
## Problem
Starting a tool instance with a Config Profile git mount can omit the mount when the repository was already cloned into the instance cache. The startup log reports that the destination path already exists and is not an empty directory.
## Root cause
`clone_git_repo()` computes a deterministic cache directory but calls `clone_repository()` before checking whether that directory already contains a clone. `git clone` therefore fails on repeated starts or overlapping start requests. `resolve_single_git_mount()` treats auxiliary mount failures as non-blocking, so startup continues without the configured volume.
## Required behavior
1. A valid existing git mount clone must be reused and updated instead of cloned again.
2. A missing clone must still be created normally.
3. An incomplete clone directory must not permanently prevent a later retry.
4. A clone/update failure remains non-blocking at the git mount resolver boundary.
## Scope
- Correct clone-cache handling in `apps/api/src/services/tool/instance_service.py`.
- Add focused regression tests in `apps/api/tests/unit/test_instance_service.py`.
- No API, database, frontend, or Docker Compose contract changes.
## Verification
- Targeted `pytest` for git mount clone reuse and instance service tests.
- Ruff and mypy checks for changed backend files.
@@ -0,0 +1,9 @@
# Tasks: fix config profile git mount clone reuse
- [x] Capture the failing runtime trace from an affected tool session.
- [x] Add a regression test proving a valid cached clone is reused.
- [x] Update `clone_git_repo()` to check the deterministic clone path before cloning.
- [x] Recover safely from an incomplete clone directory.
- [x] Run targeted backend tests and quality checks.
- [x] Update project maps and validate map freshness.
- [x] Commit the verified fix.
@@ -0,0 +1,30 @@
# Restore Mobile Terminal Scrolling
## Summary
The recent terminal scrollback optimization disabled scrollback for every viewport. Mobile terminal swipe handling still scrolls xterm's normal buffer programmatically, so swipes in normal-buffer tools no longer have retained output to move through.
## Root Cause
`468f342` changed the terminal configuration to `scrollback: 0` globally to prevent stale repaint frames and wheel scrolling on desktop. The mobile touch handler calls `term.scrollLines()` when the normal buffer is active. With zero scrollback, that call has no scrollable history and becomes a no-op.
## Scope
- `apps/web/src/components/features/terminal/terminal.tsx`
- Focused terminal configuration test
## Fix
Retain a bounded xterm scrollback buffer on mobile only (`10000` lines), while leaving desktop at zero scrollback and with wheel sensitivity disabled. Mobile's existing custom touch handler remains responsible for moving through normal-buffer history; alternate-screen swipes continue to send SGR wheel events to the active TUI.
## Acceptance Criteria
- [ ] A mobile terminal with normal-buffer output exceeding one screen scrolls via a vertical swipe.
- [ ] Alternate-screen terminal scrolling continues to use the existing SGR wheel-event path.
- [ ] Desktop keeps zero xterm scrollback and disabled native wheel scrolling, so stale repaint frames do not return.
- [ ] Focused unit test and frontend quality gates pass.
## Related
- `468f342 fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools`
- `openspec/changes/fix-terminal-container-overflow`
@@ -0,0 +1,9 @@
# Restore Mobile Terminal Scrolling — Tasks
- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop.
- [x] Reinitialize the terminal when the responsive mobile classification changes so its scrollback and touch handler match the active viewport.
- [x] Add focused tests for the responsive scrollback configuration.
- [x] Run the full frontend test suite (89 tests passed after repairing the `ProjectsPage` test setup).
- [x] Run frontend typecheck, lint, focused tests, and production build.
- [ ] Perform mobile normal-buffer and alternate-screen manual QA.
- [ ] Update project maps for changed source files (the map patch tool currently fails with an unsupported `temperature` parameter).
@@ -12,6 +12,7 @@ After implementing configurable tool container home directories, new `pi-agent`
4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them.
5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails.
6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory.
7. Config-profile file mounts use canonical profile storage owned by the API process. A non-root container user therefore cannot write to writable bind mounts. When a directory-level profile mount and a Git mount share or nest under the same target, Docker bind mounting masks the earlier source rather than merging their files.
## Fix
@@ -40,7 +41,9 @@ After implementing configurable tool container home directories, new `pi-agent`
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
9. Update unit tests for the new behavior.
9. Stage every config-profile bind-mount source into the instance directory before compose generation. This makes writable mounts user-owned without changing the shared canonical profile source.
10. Replace overlapping profile and Git bind mounts with a per-instance composite source. The composite copies Git content first and profile content second, preserving Git siblings while allowing profile files to override matching paths; it is then chowned with the other staged mounts. This removes duplicate/nested Docker mounts rather than relying on mount order to merge them.
11. Update unit tests for the new behavior.
## Affected files
@@ -12,5 +12,8 @@
- [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges
- [x] Pass manifest-declared container user to terminal sessions via `docker exec --user`
- [x] Update unit tests for container user/terminal changes
- [x] Stage profile bind mounts per instance so writable sources are owned by the container user
- [x] Composite overlapping profile and Git mounts into one per-instance bind source
- [x] Add regression tests for mount composition, ownership staging, and mount order
- [ ] Run quality gates for container user/terminal changes
- [ ] Commit and push
@@ -0,0 +1,14 @@
# Fix Web Terminal Clipboard
## Problem
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
## Required behavior
- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- Browser native context menus remain available when no terminal output is selected.
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
- Copy requests expose the xterm selection as plain text.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection.
@@ -0,0 +1,6 @@
# Web Terminal Clipboard Tasks
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
- [x] Normalize browser paste handling through the existing paste transport.
- [x] Add focused frontend coverage for copy shortcut decisions.
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.
@@ -0,0 +1,35 @@
# Fix Web Terminal Resilience
## Summary
Prevent large browser pastes from blocking flow-control acknowledgements, and prevent stale reconnect callbacks from replacing a healthy terminal WebSocket.
## Problem
The terminal WebSocket handler awaits each PTY write inline. A large paste can wait for the PTY to become writable while the same handler stops receiving acknowledgement messages. If output flow control has paused PTY reads, the acknowledgement that would resume output remains unread, leaving the terminal apparently frozen.
Separately, reconnect timers and visibility callbacks can create a second socket after a connection becomes healthy. The terminal manager then closes the existing session socket, interrupting active input or rendering.
## Scope
- Queue bounded terminal input onto a single ordered writer so the WebSocket receive loop continues handling acknowledgements, resize, reset, and disconnect messages.
- Make browser reconnection single-owner: stale socket callbacks and retry timers MUST NOT replace a current healthy socket.
- Add focused regression tests for the queueing and reconnect behavior.
## Non-goals
- Re-enable desktop normal-buffer scrollback or mouse-wheel scrolling. Desktop continues to use zero scrollback and disabled wheel sensitivity to avoid the known stale TUI-frame regression.
- Change terminal session persistence, authentication, PTY transport, or mobile touch scrolling behavior.
## Risk and rollback
The bounded input queue must preserve input ordering, reject excess input without blocking control messages, and be cancelled when the WebSocket disconnects. Socket ownership checks must not prevent a legitimate reconnect after a real disconnect. Roll back by reverting the backend queue and frontend ownership changes; existing direct PTY input and retry behavior then resumes.
## Acceptance Criteria
- [ ] A blocked PTY write does not prevent the WebSocket handler from processing a subsequent flow-control acknowledgement.
- [ ] Input bytes are still written to the PTY in arrival order, and excess queued input is rejected rather than growing without limit.
- [ ] A stale socket close event or retry callback cannot replace an open current socket.
- [ ] Component cleanup cancels pending reconnect timers and closes the current socket.
- [ ] Desktop scrollback and wheel settings remain unchanged.
- [ ] Focused backend/frontend tests and relevant quality gates pass.
@@ -0,0 +1,27 @@
# Fix Web Terminal Resilience — Tasks
## Review Workload Forecast
| Field | Value |
| ------- | ------- |
| Estimated changed lines | 180280 |
| 400-line budget risk | Low |
| Chained PRs recommended | No |
| Suggested split | Single focused change |
| Delivery strategy | single-pr |
| Chain strategy | feature-branch-chain |
Decision needed before apply: No
Chained PRs recommended: No
Chain strategy: feature-branch-chain
400-line budget risk: Low
## Tasks
- [x] **RED — backend input/control concurrency:** characterize a PTY write that waits for readiness while an acknowledgement is received; prove the acknowledgement is handled without waiting for that write to finish.
- [x] **GREEN — ordered input writer:** move PTY writes behind one cancellable ordered queue/worker while retaining the current public WebSocket message protocol and input ordering.
- [x] **TRIANGULATE — lifecycle:** cover worker cancellation and queued-write failure/disconnect handling.
- [x] **RED — frontend socket ownership:** characterize stale close/retry callbacks after a newer socket has become current.
- [x] **GREEN — reconnect ownership:** ensure only the current socket can update state or schedule a retry; cancel retry timers during cleanup.
- [x] **REFACTOR:** keep the connection lifecycle readable and avoid changing the intentional desktop scrollback configuration.
- [x] **Verify:** run targeted backend and frontend tests, frontend typecheck/lint/build, backend checks practical in the isolated worktree, and inspect diagnostics. (Backend pytest is unavailable locally: no pytest/uv executable; Docker test execution was explicitly declined.)
@@ -0,0 +1,33 @@
# Live Config Profile Refresh
## Summary
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
## Scope
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
- Return per-instance refresh results to the profile-save UI.
## Constraints
- Preserve running containers and terminal sessions.
- Preserve the workspace/repository mount.
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
- Desktop and mobile profile editors use the same save/refresh behavior.
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
## Acceptance Criteria
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
- [ ] Topology changes return a restart-required result without recreating the instance.
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
@@ -0,0 +1,17 @@
# Design: Live Config Profile Refresh
## Canonical working copies
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
## Container compatibility
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
## Save behavior
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
## Scope boundaries
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
@@ -0,0 +1,19 @@
# Implementation Plan: Live Config Profile Refresh
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
## Delivery order
1. Container-user compatibility
2. Canonical non-Git profile mounts
3. API and deletion semantics
4. UI feedback
5. Verification and commit
## Deferred
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
@@ -0,0 +1,34 @@
# Live Config Profile Refresh — Tasks
## Review Workload Forecast
| Field | Value |
| --- | --- |
| Estimated changed lines | 500750 |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
| Delivery strategy | feature-branch-chain |
| Chain strategy | feature-branch-chain |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: feature-branch-chain
400-line budget risk: High
## Execution Plan
1. [x] **Canonical non-Git mounts:** bind declared profile files and mount directories directly from profile-scoped canonical storage; ensure the container user owns writable canonical sources.
2. [x] **Editor synchronization:** materialize editor saves into canonical storage and return canonical declared-file content through profile responses; add explicit Reload working copy controls in desktop and mobile editors.
3. [x] **Writable Git working copies:** expose profile-scoped Git sources as writable mounts and replace them from the configured remote ref on explicit refresh.
4. [x] **Server refresh contract:** require explicit destructive-refresh confirmation at the API boundary and return a typed outcome when confirmation is missing.
5. [x] **Overlap safety:** replace profile/Git composite snapshots with child file-level canonical profile overlays so Git directory mounts remain intact.
6. [x] **Outcome UI:** display destructive-refresh confirmation and explicit Reload working copy controls in both editor layouts; restart-required/overwrite states remain available as API errors/outcomes.
7. [x] **Focused tests:** cover destructive refresh and overlap behavior at service/API/frontend levels; canonical source reuse is covered by resolver tests.
8. [x] **Verify:** targeted backend/frontend tests, typecheck, lint, and diagnostics passed. Docker multi-instance validation remains skipped by user choice.
## Verification Notes
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
@@ -0,0 +1,28 @@
# Test Plan: Live Config Profile Refresh
## Backend
- Canonical file and directory paths are profile-scoped and reject traversal.
- Two compatible instances receive the same host mount source.
- A container-side file edit is visible through the profile read API and another instance mount.
- A profile save updates canonical content and returns overwrite warnings when applicable.
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
- Incompatible users return `incompatible_permissions`.
- Deleting a profile with running dependents is rejected with their instance identifiers.
- Git mount content is unchanged by this feature.
## Container/image compatibility
- Each built-in supported image uses the common non-root UID/GID.
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
- Existing instances are not mutated until restart/recreation.
## Frontend
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
## Manual QA
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
- Save a profile edit and verify both running instances see it without terminal disconnection.
- Verify profile deletion is blocked while either instance is running.
@@ -0,0 +1,26 @@
# Live Git Config Mount Refresh
## Why
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
## Change
Move Git Config Profile mounts to profile-scoped writable working copies shared by compatible instances and the profile editor. Refresh a stable branch/ref checkout in place under a per-clone lock, explicitly replacing local working-copy edits.
## Scope
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
- In-place refresh for existing directory mounts only.
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
- Writable profile-scoped Git working copies shared by compatible instances and the profile editor.
- Explicit destructive-refresh warning before local Git working-copy edits are replaced.
- An in-progress indicator that prevents duplicate refresh requests in desktop and mobile Config Profile views.
## Out of scope
- Per-instance writable Git working copies that diverge from the profile-scoped working copy.
- Global cross-user clone sharing.
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
- Atomic all-files revision switching for processes already reading the mount.
- Automatic refresh of an editor buffer that has already loaded a mounted file.
@@ -0,0 +1,32 @@
# Design: Live Git Config Mount Refresh
## Canonical source
Each selected Config Profile owns a writable Git working copy beneath:
```text
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
```
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; working copies are never shared across users, but are shared by compatible instances that selected the same profile.
## Runtime behavior
1. Resolve Git mounts and map them to canonical sources.
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
3. Clone into a temporary sibling, then rename on initial creation.
4. For refresh, fetch and hard-reset the existing working tree in place, replacing local container/editor edits after an explicit warning.
5. Bind directory mappings writable. Compatible containers and the profile editor share the same working-copy files.
6. When profile and Git mount paths overlap, the instance-local composite source must be synchronized in place during refresh; replacing its root directory would leave a running bind mount attached to the old inode.
## Boundaries
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
- Refresh failure is reported without mutating a known-good checkout.
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
- A refresh warning must state that local Git working-copy edits will be replaced.
- A browser editor that already has a file open is not a filesystem watcher; the user must reload that editor buffer after the mounted source changes.
## Security
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
@@ -0,0 +1,16 @@
# Live Git Config Mount Refresh — Tasks
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
- [x] Make Git Config Profile mounts profile-scoped writable working copies shared with compatible instances and the editor.
- [x] Add lock-protected destructive refresh with an explicit local-edit replacement warning.
- [x] Add save/refresh outcomes for live refresh, destructive-refresh confirmation, and failures; use file-level overlays to avoid live topology changes.
- [x] Add an in-progress desktop/mobile indicator that disables duplicate Git-mount refresh requests.
- [x] Replace instance-local composites with file-level canonical profile overlays, so live updates do not depend on composite synchronization.
- [x] Add focused resolver/service/API/frontend tests.
- [x] Run available verification and document skipped checks.
## Verification Notes
- Passed: 47 targeted backend tests, Ruff, mypy, frontend API test, and frontend production build.
- Skipped: Docker/manual multi-instance checks were explicitly declined.
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
+4 -3
View File
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
sudo \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user and allow passwordless sudo so the startup
# permission fixer can adjust ownership of bind-mounted directories.
RUN useradd -m -s /bin/bash user \
# Use the shared built-in UID/GID so writable Config Profile mounts can be
# shared by compatible instances without ownership changes.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
&& chmod 0440 /etc/sudoers.d/user
WORKDIR /home/user
+5 -1
View File
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Set up git
RUN git config --global init.defaultBranch main
# Code-server runs as abc user by default
# The LinuxServer entrypoint maps abc to this shared UID/GID before starting
# code-server, so writable Config Profile mounts are compatible with other
# built-in tool containers.
ENV PUID=1000 \
PGID=1000
USER abc
EXPOSE 8443
+5 -2
View File
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
# Set up git
RUN git config --global init.defaultBranch main
# Switch back to jovyan user (default for scipy-notebook)
USER ${NB_UID}
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
# Keep the image root at entrypoint time so that mapping can occur.
ENV NB_UID=1000 \
NB_GID=1000
USER root
EXPOSE 8888
+3 -2
View File
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install OpenCode
RUN npm install -g opencode
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# Set up git
+8 -11
View File
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install Pi Coding Agent globally
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# Set up git
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
&& git config --global user.email "dev@headquarter.local" \
&& git config --global user.name "Developer"
# Create default tmux config
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
# Create user-owned default configuration files.
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf \
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
&& chown -R user:user /home/user
USER user