Compare commits

..

31 Commits

Author SHA1 Message Date
alex 22474cdba5 style: fix all ruff and eslint errors across codebase
Backend (ruff):
- Fix 106 errors: move imports to top of file (E402)
- Remove unused imports (F401)
- Add missing imports for undefined names (F821)
- Remove unused variables (F841)
- Fix test_models.py broken RefreshToken test
- Fix test_projects_api.py missing TestClient import

Frontend (eslint):
- Remove unused imports/variables across 10 files
- Fix explicit any types in client.ts and sessions.ts
- Clean up empty block statements in terminal.tsx

Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass),
pytest (98 passed, 4 pre-existing failures)
2026-05-28 10:15:59 +02:00
alex 0c839e8c6f fix: terminal 4004 infinite reconnect loop for pi-agent tool type
- Add stdin_open: true and tty: true to dockerfile-based compose generation.
  Without these, bash (PID 1) exits immediately, causing a container restart
  loop that makes the instance invisible to docker ps and triggers 4004.
- Treat WebSocket close codes 4001/4003/4004 as permanent errors in the
  frontend. Stop retrying and show the server reason to the user.
- Prevent visibilitychange handler from resetting retry attempts after a
  permanent error has occurred.
- Use docker ps -a in get_container_id/get_container_name to find
  stopped/exited containers for diagnostics.

Quality gates: tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
2026-05-28 09:33:35 +02:00
Alex Blank c63cf7db50 fix: handle existing git mount dirs and invalid compose ports
- Fix git mount clone to check correct path (repo-clone subdir)
- Pull updates instead of re-cloning when git mount dir exists
- Add compose file sanitization to remove invalid port 0 mappings
- Fixes startup failures for existing instances with old compose files
2026-05-27 22:34:24 +02:00
Alex Blank d9d2b91384 fix: skip port mapping for terminal-only tools in dockerfile compose 2026-05-27 22:29:37 +02:00
Alex Blank d6ea5fb1fd chore: clean up debugging logs and console prints
Frontend:
- Remove 18 console.log/warn/error statements from terminal.tsx
- Remove console.warn from icon.tsx

Backend:
- Downgrade routine logger.info to logger.debug in tool_instances.py, terminal.py,
  terminal_session.py, terminal_manager.py, auth.py, docker_build.py, clone.py,
  config_profiles.py, user_config.py
- Keep important lifecycle events as logger.info:
  * Instance creation, start, running state
  * Docker build success/failure
  * Terminal session creation and reset
  * Auth success and user creation
  * Readiness probe success
  * Tunnel creation/stop
2026-05-27 22:16:59 +02:00
Alex Blank 1883825b18 fix: run docker build in thread pool to avoid blocking API 2026-05-27 22:08:01 +02:00
Alex Blank bc71fd6fac fix: lowercase docker image tag to avoid invalid reference format 2026-05-27 22:02:18 +02:00
Alex Blank 28aa9ccf5a feat: add pi-agent tool type migration
- Adds pi-agent to tool_types table with terminal interface
- Includes Dockerfile template for pi.dev coding agent
- Idempotent: checks for existing entry before insert
2026-05-27 16:30:27 +02:00
Alex Blank 44dd80cb58 feat: add tool-images directory with Dockerfiles for all base tool types
- Create tool-images/ directory with organized Dockerfile templates
- Add base.dockerfile with common dev tools (git, nvim, ranger, tmux, node)
- Add code-server.dockerfile with VS Code in browser
- Add jupyter.dockerfile with Jupyter Lab
- Add opencode.dockerfile with OpenCode agent
- Add pi-agent.dockerfile with Pi Coding Agent (pi.dev)
- All images include: git, neovim, ranger, tmux, htop, tree, jq, Node.js 20
- Add README with usage instructions
2026-05-27 15:51:43 +02:00
Alex Blank 23485833d8 docs: clarify git mount target path hint 2026-05-27 15:34:50 +02:00
Alex Blank e23dcdf4e1 fix: remove broken ~ expansion, require working_directory for relative paths 2026-05-27 15:33:06 +02:00
Alex Blank f05ac55875 fix: expand ~ in git mount target paths to avoid /tmp 2026-05-27 15:27:44 +02:00
Alex Blank bcefeb4163 fix: git mount add button not adding mounts 2026-05-27 15:17:34 +02:00
Alex Blank 33d08faf70 feat: allow relative target paths for git mounts
- Remove absolute path requirement from target_path validation
- Resolve relative paths against working_directory at instance startup
- Fall back to /home/user if no working_directory is configured
- Update frontend to allow relative target paths
- Update spec to document relative path support
- Update tests to allow relative paths and test path traversal rejection
2026-05-27 15:01:16 +02:00
Alex Blank 8a58c61278 fix: align git mount implementation with spec
- _checkout_branch now returns bool and falls back gracefully on failure
- Glob warning message includes matched file count
- Fix database model comment to reference remote_url
- Update tests for new branch checkout behavior

All 51 tests pass
2026-05-27 14:38:11 +02:00
Alex Blank 8231e750d9 docs: update spec to use remote_url instead of repo_id for git mounts
- Change git mount schema from repo_id to remote_url
- Update validation rules to check URL format instead of repo existence
- Update cloning scenarios to clone directly from URL
- Update UI scenarios to show URL input instead of repo selector
- Remove references to internal/existing repositories
2026-05-27 14:27:39 +02:00
Alex Blank 6ce645d210 fix: correct API endpoints for repository listing
- Change frontend to call /repositories instead of /projects/repositories
- Change parse-url endpoint to /repositories/parse-url
- Fixes 422 error from route mismatch
2026-05-27 14:18:13 +02:00
Alex Blank 89ca9f10c7 feat: simplify git mounts to use direct URLs instead of repo references
- Change git mount schema from repo_id to remote_url
- Remove database lookups for git mount resolution
- Clone directly from URL at instance startup
- Simplify frontend UI to text input for Git URL
- Fix route ordering in git_repositories.py to prevent 422 errors
- Update all tests to use remote_url field

Breaking change: Git mounts now use remote_url instead of repo_id
2026-05-27 11:53:25 +02:00
Alex Blank baabd1fa62 feat: allow creating external repositories directly from git mount editor
- Add createExternalRepository API function
- Update GitMountEditor with "+ Add new repository..." option
- Show form to enter repo name and remote URL
- Auto-create external repo and refresh list on success
- Update config-profiles page to pass onCreateRepository handler
2026-05-27 11:17:27 +02:00
Alex Blank f14fc37e75 docs: update API docs for external repositories and git mounts
- Add GET /repositories endpoint documentation
- Add POST /repositories endpoint for external repos
- Update config-profiles.md with git mount details
- Update repositories.md with external repo support
2026-05-27 11:04:48 +02:00
Alex Blank e07938098a feat: add external repository support for config profile git mounts
- Add POST /repositories endpoint for external repos (no project_id)
- Update GitRepositoryResponse to allow nullable project_id
- Update list_repositories to support listing all user repos
- Add _pull_repository_updates for auto-pull on container creation
- Update git mount validation to allow external repos
- Frontend: Update listRepositories to support optional projectId
- Spec updates: external repos, auto-clone, per-instance isolation
2026-05-27 11:03:12 +02:00
Alex Blank 943b9db5c7 fix: remove merge migration that references non-existent file
The server is missing 2026_05_27_make_project_id_nullable.py but the
merge migration referenced it. Removing the merge migration leaves a
clean single head chain.
2026-05-27 10:48:25 +02:00
Alex Blank a4604d6a9a fix: add merge migration to resolve multiple heads from renamed migration
The server has both the old (2026_05_27_make_project_id_nullable) and
new (2026_05_27_external_repos) migration files, creating two heads.
This merge migration resolves them into a single head.
2026-05-27 10:42:44 +02:00
Alex Blank 18204628cc fix: shorten alembic revision name and expand version_num column
- Rename 2026_05_27_make_project_id_nullable to 2026_05_27_external_repos
  (33 chars exceeded VARCHAR(32) limit in alembic_version table)
- Add alembic_version column expansion to VARCHAR(64) in migration
- Ensure future migrations won't hit the 32 char limit
2026-05-27 10:36:47 +02:00
Alex Blank 93b415c53e feat: support external repositories for git mounts
- Make project_id nullable in git_repositories table (migration)
- Allow external repos not tied to any project
- Update validation to allow user-owned external repos in git mounts
- Add /projects/repositories endpoint to list all user repos
- Update frontend to fetch all user repos for git mount selector
- TypeScript and build pass
2026-05-27 10:28:31 +02:00
Alex Blank e7adfb462b fix: use correct revision ID for alembic down_revision 2026-05-27 10:21:20 +02:00
Alex Blank ed1d6528c6 fix: resolve alembic multiple heads by correcting migration dependency chain 2026-05-27 10:16:06 +02:00
Alex Blank c4be7163d6 docs: add config profile git mounts documentation
- API documentation for config profiles with git mounts endpoint details
- User guide for using git repositories in config profiles
- Document branch pinning, glob patterns, error handling, and best practices
- Update API README to link to new config-profiles documentation
2026-05-26 22:54:59 +02:00
Alex Blank 13f55fff47 docs: mark test tasks as complete in spec 2026-05-26 22:50:06 +02:00
Alex Blank 0ec20b9c23 test: add comprehensive tests for config profile git mounts
- Add git mount merge function tests
- Add profile resolution tests with git mounts
- Add integration tests for CRUD with git mounts
- Add glob expansion tests (patterns, limits, repo boundary)
- Add branch checkout tests (success and failure)
- Add error handling tests for missing repos/invalid UUIDs

All 52 tests pass.
2026-05-26 22:49:12 +02:00
Alex Blank 4c11163bff feat: implement config profile git mounts
- Add git_mounts column to config_profiles table (JSONB)
- Create GitMount Pydantic models with validation
- Add repo validation in create/update endpoints
- Update profile resolver to merge git mounts from includes
- Implement auto-clone, branch checkout, and glob expansion
- Add parallel processing for git mount resolution
- Create GitMountEditor frontend component
- Update TypeScript types and API clients
- Add CSS styles for git mount UI
- Frontend type check and build pass

Implements tasks 1.1-7.9 of config-profile-git-mounts spec
2026-05-26 22:27:04 +02:00
75 changed files with 3698 additions and 937 deletions
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
@@ -0,0 +1,129 @@
"""add pi agent tool type
Revision ID: 20260527_160017_add_pi_agent
Revises: f3d2dc90ba3a
Create Date: 2026-05-27T16:00:17
"""
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision: str = "20260527_160017_add_pi_agent"
down_revision: Union[str, None] = "2026_05_27_external_repos"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
def upgrade() -> None:
# Check if pi-agent already exists
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
).fetchone()
if result is None:
conn.execute(
sa.text("""
INSERT INTO tool_types (
id, name, display_name, description, category,
interface_type, requires_port, default_port,
definition_type, compose_template, dockerfile_template, required_variables,
created_at, updated_at
) VALUES (
:id, :name, :display_name, :description, :category,
:interface_type, :requires_port, :default_port,
:definition_type, :compose_template, :dockerfile_template, :required_variables,
now(), now()
)
"""),
{
"id": PI_AGENT_ID,
"name": "pi-agent",
"display_name": "Pi Agent",
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
"category": "development",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "dockerfile",
"compose_template": """services:
app:
build: .
stdin_open: true
tty: true
volumes:
- ${REPO_PATH}:/workspace
working_dir: /workspace
command: /bin/bash""",
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install base dependencies
RUN apt-get update && apt-get install -y \\
curl \\
wget \\
git \\
neovim \\
ranger \\
tmux \\
htop \\
tree \\
jq \\
ca-certificates \\
python3 \\
python3-pip \\
build-essential \\
&& rm -rf /var/lib/apt/lists/*
# Install Node.js (required for Pi)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
&& apt-get install -y nodejs \\
&& rm -rf /var/lib/apt/lists/*
# 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
WORKDIR /home/user
# Set up git
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 echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
USER user
# Default to bash (Pi is invoked manually via `pi` command)
CMD ["/bin/bash"]""",
"required_variables": json.dumps(["REPO_PATH"]),
}
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
)
@@ -0,0 +1,28 @@
"""add_git_mounts_to_config_profiles
Revision ID: 2026_05_26_add_git_mounts
Revises: f3d2dc90ba3a
Create Date: 2026-05-26 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_26_add_git_mounts"
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"config_profiles",
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
)
def downgrade() -> None:
op.drop_column("config_profiles", "git_mounts")
@@ -0,0 +1,41 @@
"""make_project_id_nullable_in_git_repositories
Revision ID: 2026_05_27_external_repos
Revises: 2026_05_26_add_git_mounts
Create Date: 2026-05-27 08:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_27_external_repos"
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Expand alembic_version version_num to avoid truncation errors
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
# Make project_id nullable to allow external repositories
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=True,
)
def downgrade() -> None:
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=False,
)
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
Create Date: 2026-05-24 18:00:43.990361
"""
from alembic import op
import sqlalchemy as sa
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a"
+10 -10
View File
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
redirect_uri=redirect_uri,
state=state,
)
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
response = RedirectResponse(location)
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
@@ -63,7 +63,7 @@ async def callback(
auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
if auth_state is None or auth_state != state:
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
@@ -71,7 +71,7 @@ async def callback(
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
async with httpx.AsyncClient() as client:
try:
@@ -92,7 +92,7 @@ async def callback(
access_token=token_payload["access_token"],
client=client,
)
logger.info("User info fetched successfully")
logger.debug("User info fetched successfully")
except Exception as exc:
logger.error("User info fetch failed: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
@@ -100,19 +100,19 @@ async def callback(
authentik_id = str(user_info.get("sub", ""))
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
name = str(user_info.get("name", email))
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
try:
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
logger.info("Creating new user: authentik_id=%s", authentik_id)
logger.debug("Creating new user: authentik_id=%s", authentik_id)
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("New user created: id=%s", user.id)
else:
logger.info("Existing user found: id=%s, updating info", user.id)
logger.debug("Existing user found: id=%s, updating info", user.id)
user.email = email
user.name = name
await session.commit()
@@ -165,20 +165,20 @@ async def me(
session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
if not session_cookie:
logger.warning("Auth /me: missing session cookie")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = payload["user_id"]
logger.info("Auth /me: decoded session for user_id=%s", user_id)
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
except ValueError as exc:
logger.warning("Auth /me: invalid session: %s", exc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
+80 -4
View File
@@ -56,6 +56,36 @@ def _calculate_profile_size(data: dict) -> int:
return total
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
target_path: str = Field(description="Absolute path inside container")
branch: str | None = Field(default=None, description="Optional branch or tag name")
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
@@ -98,6 +128,7 @@ class ConfigProfileCreate(BaseModel):
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
@field_validator("project_id", "tool_type_id")
@@ -150,6 +181,7 @@ class ConfigProfileUpdate(BaseModel):
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
@field_validator("project_id", "tool_type_id")
@@ -193,6 +225,7 @@ class ConfigProfileResponse(BaseModel):
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
@@ -227,6 +260,32 @@ async def _check_access(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[dict],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
return {
"id": str(profile.id),
@@ -238,6 +297,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
@@ -321,6 +381,11 @@ async def create_config_profile(
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
@@ -339,6 +404,7 @@ async def create_config_profile(
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
@@ -353,7 +419,7 @@ async def create_config_profile(
)
profile = result.scalar_one()
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
@@ -415,6 +481,14 @@ async def update_config_profile(
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
# Check size
current_data = _profile_to_response(profile)
@@ -432,6 +506,8 @@ async def update_config_profile(
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
@@ -444,7 +520,7 @@ async def update_config_profile(
)
profile = result.scalar_one()
logger.info("Updated config profile %s", profile.id)
logger.debug("Updated config profile %s", profile.id)
return _profile_to_response(profile)
@@ -464,7 +540,7 @@ async def delete_config_profile(
await session.delete(profile)
await session.commit()
logger.info("Deleted config profile %s", profile_id)
logger.debug("Deleted config profile %s", profile_id)
return None
@@ -549,7 +625,7 @@ async def update_profile_includes(
)
direct_includes = inc_result.scalars().all()
logger.info("Updated includes for config profile %s", profile.id)
logger.debug("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
+151 -22
View File
@@ -13,9 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.config import Settings
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import (
commit_file,
get_file_content,
@@ -222,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
@@ -232,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
updated_at: datetime
@router.get(
"/repositories",
response_model=list[GitRepositoryResponse],
summary="List all user repositories",
description="List all git repositories owned by the user, including external repositories not tied to any project.",
)
async def list_user_repositories(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories owned by the user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of all repositories owned by the user.
"""
result = await session.execute(
select(GitRepository).where(GitRepository.owner_id == user_id)
)
return list(result.scalars().all())
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an external repository",
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
)
async def create_external_repository(
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new external git repository.
External repositories are not tied to any project and can be used
across all projects for config profile git mounts.
Args:
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created external repository.
"""
_user = await _get_user(session, user_id)
# Check for duplicate name (external repos only)
existing = await session.execute(
select(GitRepository).where(
GitRepository.project_id.is_(None),
GitRepository.owner_id == user_id,
GitRepository.name == data.name,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
# Validate and potentially correct the URL
remote_url = data.remote_url
if remote_url and not data.force_original_url:
parse_result = parse_git_url(remote_url)
if parse_result["needs_parsing"] and parse_result["base_url"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "The provided URL appears to be a browser URL, not a git clone URL",
"suggested_url": parse_result["base_url"],
"original_url": remote_url,
"error_code": "URL_NEEDS_PARSING",
},
)
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
# Create external repo with no project
repo = GitRepository(
name=data.name,
path="", # Will be set after clone
project_id=None,
owner_id=user_id,
remote_url=remote_url,
ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.flush()
# Set path and optionally clone
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
repo.path = repo_path
if remote_url:
try:
_clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
else:
# Initialize empty repo
os.makedirs(repo_path, exist_ok=True)
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
repo.is_mirror = False
await session.commit()
return repo
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
@@ -301,25 +449,6 @@ async def delete_repository(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
+1 -2
View File
@@ -4,11 +4,10 @@ import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
-1
View File
@@ -2,7 +2,6 @@
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
-1
View File
@@ -11,7 +11,6 @@ from src.auth.dependencies import _get_owned_project, _get_user, get_current_use
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
-1
View File
@@ -1,6 +1,5 @@
"""Shared Pydantic validators for API schemas."""
from typing import Any
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
-1
View File
@@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
+13 -13
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
@@ -44,9 +44,9 @@ async def terminal_websocket(
Returns:
None. Communicates via WebSocket messages.
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
try:
# Parse instance_id
@@ -80,13 +80,13 @@ async def terminal_websocket(
await websocket.close(code=4004, reason="Instance not running")
return
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.info("Using startup command for instance %s: %s", instance_id, startup_command)
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
# Get or create terminal session
try:
@@ -95,15 +95,15 @@ async def terminal_websocket(
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
logger.info("WebSocket attached to session for instance %s", instance_id)
logger.debug("WebSocket attached to session for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
logger.info("Sent connected status for instance %s", instance_id)
logger.debug("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
@@ -112,7 +112,7 @@ async def terminal_websocket(
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
logger.info("Started terminal loops for instance %s", instance_id)
logger.debug("Started terminal loops for instance %s", instance_id)
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
@@ -120,7 +120,7 @@ async def terminal_websocket(
return_when=asyncio.FIRST_COMPLETED,
)
logger.info("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
# Cancel remaining tasks
for task in pending:
@@ -134,7 +134,7 @@ async def terminal_websocket(
try:
if 'session' in locals():
await terminal_manager.detach_websocket(session, websocket)
logger.info("WebSocket detached from session for instance %s", instance_id)
logger.debug("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.info("Resetting terminal session for instance %s", session.instance_id)
logger.debug("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
+1 -1
View File
@@ -1,6 +1,5 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
@@ -11,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,7 +1,6 @@
import uuid
from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import (
check_port_exposed,
sanitize_template_vars,
validate_compose_yaml,
validate_required_variables,
)
+5 -6
View File
@@ -1,17 +1,16 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/users/me", tags=["user-config"])
@@ -103,11 +102,11 @@ async def update_user_config(
# Merge updates
update_data = data.model_dump(exclude_unset=True)
logger.info("Updating user config for user %s: %s", user_id, update_data)
logger.debug("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
await session.commit()
await session.refresh(config)
logger.info("Updated config: %s", config.config)
logger.debug("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
+1
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User
-2
View File
@@ -1,4 +1,3 @@
import json
import logging
import os
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
+3
View File
@@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"rel/path": "content", ...}
git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship()
+1 -1
View File
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024))
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+3 -3
View File
@@ -42,7 +42,7 @@ def clone_repository(
str(clone_path),
]
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
result = subprocess.run(
cmd,
capture_output=True,
@@ -55,7 +55,7 @@ def clone_repository(
logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
logger.info("Successfully cloned repository into %s", clone_path)
logger.debug("Successfully cloned repository into %s", clone_path)
return str(clone_path)
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
if clone_path.exists():
import shutil
shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path)
logger.debug("Removed clone directory: %s", clone_path)
@@ -48,6 +48,7 @@ class ResolvedProfile:
env_vars: dict[str, str] = field(default_factory=dict)
runtime_hints: dict[str, Any] = field(default_factory=dict)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
git_mounts: list[dict[str, Any]] = field(default_factory=list)
files: dict[str, str] = field(default_factory=dict)
env_overrides: dict[str, str] = field(default_factory=dict)
hint_overrides: dict[str, str] = field(default_factory=dict)
@@ -168,6 +169,28 @@ def _merge_mounts(
return result
def _merge_git_mounts(
base: list[dict[str, Any]],
overlay: list[dict[str, Any]],
source_name: str,
) -> list[dict[str, Any]]:
"""Merge git mounts from included profiles.
Later mounts override earlier ones with the same remote_url + target_path combo.
"""
result = list(base)
# Build lookup by (remote_url, target_path)
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
for mount in overlay:
key = (mount["remote_url"], mount["target_path"])
if key in seen:
result[seen[key]] = dict(mount)
else:
seen[key] = len(result)
result.append(dict(mount))
return result
async def _resolve_profile_recursive(
session: AsyncSession,
profile_id: uuid.UUID,
@@ -244,6 +267,9 @@ async def _resolve_profile_recursive(
result.mount_overrides,
included.profile_name,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts, included.git_mounts, included.profile_name
)
# Apply the profile's own settings (selected profile overrides includes)
result.env_vars = _merge_env_vars(
@@ -270,7 +296,11 @@ async def _resolve_profile_recursive(
result.mount_overrides,
profile.name,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts,
profile.git_mounts or [],
profile.name,
)
return result
@@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"files": resolved.file_overrides,
"mounts": resolved.mount_overrides,
},
"git_mounts": resolved.git_mounts,
"included_profiles": resolved.included_profiles,
}
+71 -32
View File
@@ -1,7 +1,9 @@
"""Docker service for managing tool instances."""
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
@@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
@@ -109,7 +112,7 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
@@ -136,6 +139,8 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -143,7 +148,7 @@ def get_container_id(instance_name: str) -> str | None:
Container ID or None if not found
"""
result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
@@ -156,6 +161,8 @@ def get_container_id(instance_name: str) -> str | None:
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -163,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None:
Container name or None if not found
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
[
"docker",
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
f"name={instance_name}",
],
capture_output=True,
text=True,
)
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
def connect_container_to_network(
container_name: str, network_name: str = "backend"
) -> bool:
"""Connect a Docker container to an existing network.
Args:
@@ -198,12 +215,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker", "inspect", "-f",
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
@@ -213,12 +232,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
@@ -238,13 +257,12 @@ def wait_for_container_running(
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
@@ -252,7 +270,7 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
@@ -260,7 +278,7 @@ def wait_for_container_running(
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
@@ -268,9 +286,9 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
@@ -322,11 +340,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
@@ -344,8 +357,6 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
logger.info(
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
)
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
logger.warning(
"Container %s:%d not responding to curl checks", container_name, port
)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
@@ -384,6 +406,7 @@ def start_cloudflared_tunnel(
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
@@ -410,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
@@ -455,14 +477,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
if any(
err in error_str
for err in [
"connection refused",
"econnrefused",
"could not resolve",
"nodename",
]
):
return {
"tunnel_status": "unreachable",
"status_code": None,
+4 -5
View File
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(dockerfile)
logger.info("Wrote Dockerfile to %s", dockerfile_path)
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
# Write build context files
if build_context:
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
logger.info("Wrote build context file: %s", full_path)
logger.debug("Wrote build context file: %s", full_path)
# Build image
logger.info("Building Docker image with tag: %s", tag)
logger.debug("Building Docker image with tag: %s", tag)
cmd = [
"docker", "build",
"-t", tag,
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
text=True,
timeout=300, # 5 minute timeout for builds
)
logger.info("Docker build completed: returncode=%d", result.returncode)
logger.debug("Docker build completed: returncode=%d", result.returncode)
if result.returncode != 0:
logger.error("Docker build failed: %s", result.stderr[:1000])
return result.returncode, result.stdout, result.stderr
+4 -5
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import uuid
from typing import Any
from fastapi import WebSocket
@@ -72,11 +71,11 @@ class TerminalManager:
# Check if session is still alive
if session.is_alive():
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
return session
else:
# Session died, clean it up
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
await session.close()
del self._sessions[instance_id_str]
@@ -97,7 +96,7 @@ class TerminalManager:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
if session.has_websockets():
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
@@ -135,7 +134,7 @@ class TerminalManager:
# Close existing session if any
if instance_id_str in self._sessions:
logger.info("Resetting terminal session for instance %s", instance_id)
logger.debug("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
await old_session.close()
+4 -4
View File
@@ -60,12 +60,12 @@ class TerminalSession:
# Set the terminal size initially
self._set_terminal_size(self._cols, self._rows)
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.info(f"Using startup command for session {self.session_id}: {startup_command}")
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
else:
shell_cmd = "bash -il"
@@ -102,7 +102,7 @@ class TerminalSession:
size = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}")
@@ -159,7 +159,7 @@ class TerminalSession:
self._cols = cols
self._rows = rows
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container,
-1
View File
@@ -2,7 +2,6 @@
import subprocess
from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str:
+60 -3
View File
@@ -8,16 +8,14 @@ from unittest.mock import patch
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings, build_database_url
from src.config import Settings
from src.models.base import Base
from src.main import app
from src.auth.dependencies import get_db_session
@@ -133,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
yield test_client
@pytest.fixture
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
"""Create a project and repository directly in the database."""
import uuid
from src.models.project import Project
from src.models.git_repository import GitRepository
project_id = uuid.uuid4()
repo_id = uuid.uuid4()
user_id = None
# Get user ID from session
async def get_user_id():
nonlocal user_id
from src.auth.session import decode_session_cookie
settings = Settings()
session_cookie = authenticated_client.cookies.get("session")
if session_cookie:
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
if session:
user_id = uuid.UUID(session["user_id"])
asyncio.run(get_user_id())
if not user_id:
raise RuntimeError("Could not get user ID from authenticated client")
async def create_project_and_repo():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
project = Project(
id=project_id,
name="test-project",
description="Test project",
owner_id=user_id,
)
session.add(project)
repo = GitRepository(
id=repo_id,
name="test-repo",
path="/tmp/test-repo",
project_id=project_id,
owner_id=user_id,
remote_url="https://github.com/test/repo.git",
)
session.add(repo)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_project_and_repo())
return str(project_id), str(repo_id)
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
@@ -320,3 +320,134 @@ class TestConfigProfilesAPI:
assert response.status_code == 200
data = response.json()
assert data["profile_id"] is None
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test creating a config profile with git mounts."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "git-mount-profile",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "git-mount-profile"
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["target_path"] == "/app"
assert data["git_mounts"][0]["branch"] == "main"
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test updating git mounts on a config profile."""
_project_id, repo_id = test_project_and_repo
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-git-mounts",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update with git mounts
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "config",
"target_path": "/config",
}
],
},
)
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["source_path"] == "config"
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount source paths are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "/absolute/path",
"target_path": "/app",
}
],
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that git mount target paths with traversal are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount-target",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "../../../etc/passwd",
}
],
},
)
assert response.status_code == 422
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test previewing a profile with git mounts."""
_project_id, repo_id = test_project_and_repo
# Create profile with git mounts
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-git-mounts",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
}
],
},
)
profile_id = create_response.json()["id"]
# Preview
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
-22
View File
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
assert UserConfig.user.property.mapper.class_ is User
@pytest.mark.integration
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
columns = RefreshToken.__table__.columns
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
assert set(columns.keys()) == {
"id",
"user_id",
"token_hash",
"expires_at",
"revoked_at",
"user_agent",
"ip_address",
"created_at",
}
assert columns["token_hash"].unique is True
assert columns["revoked_at"].nullable is True
assert user_fk.target_fullname == "users.id"
assert RefreshToken.user.property.mapper.class_ is User
@pytest.mark.asyncio
@pytest.mark.integration
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
},
)
assert response.status_code == 422
data = response.json()
_ = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command."""
@@ -6,13 +6,13 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle,
resolve_profile,
_merge_env_vars,
_merge_files,
_merge_mounts,
_merge_runtime_hints,
_merge_git_mounts,
)
@@ -62,7 +62,6 @@ class TestMergeFunctions:
def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
@@ -97,6 +96,39 @@ class TestMergeFunctions:
assert result["/app"].mode == "ro"
assert overrides == {"/app": "source"}
def test_merge_git_mounts_basic(self) -> None:
"""Test basic git mount merging."""
result = _merge_git_mounts(
[],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
"source",
)
assert len(result) == 1
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result[0]["target_path"] == "/app"
def test_merge_git_mounts_override_same_repo_target(self) -> None:
"""Test that git mounts with same repo+target override."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
"source",
)
assert len(result) == 1
assert result[0]["source_path"] == "src"
assert result[0]["branch"] == "dev"
def test_merge_git_mounts_different_targets(self) -> None:
"""Test that git mounts with different targets are preserved."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
"source",
)
assert len(result) == 2
targets = {m["target_path"] for m in result}
assert targets == {"/app", "/config"}
class TestResolveProfile:
"""Unit tests for profile resolution."""
@@ -250,6 +282,76 @@ class TestResolveProfile:
with pytest.raises(ConfigProfileCycleError):
await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with git mounts."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="with-git-mounts",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result.git_mounts[0]["target_path"] == "/app"
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
"""Test resolving a profile that includes another with git mounts."""
user_id = uuid.uuid4()
# Create base profile with git mount
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(base)
# Create child profile with its own git mount
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
],
)
db_session.add(child)
await db_session.commit()
# Create include relationship
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert len(result.git_mounts) == 2
targets = {m["target_path"] for m in result.git_mounts}
assert targets == {"/app", "/config"}
@pytest.mark.asyncio
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
"""Test resolving a non-existent profile."""
@@ -0,0 +1,124 @@
"""Unit tests for git mount resolution in tool instances."""
import os
from pathlib import Path
import pytest
from src.api.tool_instances import (
_checkout_branch,
_expand_glob_source,
_resolve_single_git_mount,
)
class TestExpandGlobSource:
"""Unit tests for glob pattern expansion."""
def test_no_glob_single_file(self, tmp_path: Path) -> None:
"""Test non-glob path returns single file."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
result = _expand_glob_source(str(test_file), str(tmp_path))
assert len(result) == 1
assert result[0] == str(test_file)
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
"""Test non-glob missing file returns empty list."""
missing_file = tmp_path / "missing.txt"
result = _expand_glob_source(str(missing_file), str(tmp_path))
assert len(result) == 0
def test_glob_pattern(self, tmp_path: Path) -> None:
"""Test glob pattern matches files."""
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "other.py").write_text("code")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 2
assert all(f.endswith(".txt") for f in result)
def test_glob_recursive(self, tmp_path: Path) -> None:
"""Test recursive glob pattern."""
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "nested.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
assert len(result) == 1
assert "nested.txt" in result[0]
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
"""Test that glob matches are limited to prevent abuse."""
# Create more than 100 files
for i in range(105):
(tmp_path / f"file{i}.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 100 # MAX_GLOB_MATCHES limit
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
"""Test that glob results outside repo are filtered."""
other_dir = tmp_path.parent / "other"
other_dir.mkdir(exist_ok=True)
(other_dir / "outside.txt").write_text("content")
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
# Should only include files within tmp_path, not other_dir
assert all(r.startswith(str(tmp_path)) for r in result)
class TestCheckoutBranch:
"""Unit tests for branch checkout."""
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
"""Test checking out an existing branch."""
# Initialize git repo
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
os.system(f"cd {tmp_path} && git branch feature")
_checkout_branch(str(tmp_path), "feature")
# Verify we're on feature branch
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
assert result == "feature"
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
"""Test checking out a non-existent branch returns False."""
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
result = _checkout_branch(str(tmp_path), "nonexistent")
assert result is False
class TestResolveSingleGitMount:
"""Unit tests for resolving a single git mount."""
@pytest.mark.asyncio
async def test_resolve_missing_remote_url(self, db_session) -> None:
"""Test that missing remote_url returns empty list."""
git_mount = {
"source_path": ".",
"target_path": "/app",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@pytest.mark.asyncio
async def test_resolve_missing_target_path(self, db_session) -> None:
"""Test that missing target path returns empty list."""
git_mount = {
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@@ -1,6 +1,5 @@
"""Tests for git URL parsing utilities."""
import pytest
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
@@ -1,9 +1,7 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
@@ -3,10 +3,7 @@
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest
+2 -2
View File
@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { type AxiosRequestConfig } from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
// Track retry count per request
const retryCount = new WeakMap<any, number>();
const retryCount = new WeakMap<AxiosRequestConfig, number>();
apiClient.interceptors.response.use(
(response) => response,
+11
View File
@@ -10,6 +10,7 @@ export interface ConfigProfile {
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
files: Record<string, string>;
}
export interface GitMount {
remote_url: string;
source_path: string;
target_path: string;
branch?: string;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
@@ -35,6 +43,7 @@ export interface ResolvedProfile {
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
+22 -3
View File
@@ -31,12 +31,24 @@ export interface URLParseResult {
}
export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/projects/repositories/parse-url", { url });
const response = await apiClient.post("/repositories/parse-url", { url });
return response.data;
}
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
if (projectId) {
const response = await apiClient.get<GitRepository[]>(
`/projects/${projectId}/repositories`
);
return response.data;
}
// List all user repositories (including external)
const response = await apiClient.get<GitRepository[]>("/repositories");
return response.data;
}
export async function listAllUserRepositories(): Promise<GitRepository[]> {
const response = await apiClient.get<GitRepository[]>("/repositories");
return response.data;
}
@@ -48,6 +60,13 @@ export async function createRepository(
return response.data;
}
export async function createExternalRepository(
data: GitRepositoryCreate
): Promise<GitRepository> {
const response = await apiClient.post<GitRepository>("/repositories", data);
return response.data;
}
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
+7 -4
View File
@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import { apiClient } from "./client";
export interface ToolInstance {
@@ -80,9 +81,10 @@ export async function startInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
@@ -114,9 +116,10 @@ export async function restartInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
@@ -0,0 +1,226 @@
import { useState } from "react";
import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
}
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
remote_url: "",
source_path: ".",
target_path: "",
branch: "",
});
const handleAdd = (mount: GitMount) => {
onChange([...mounts, mount]);
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...mounts];
updatedMounts[index] = updated;
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
onChange(mounts.filter((_, i) => i !== index));
};
const validatePath = (path: string, isTarget: boolean): string | null => {
if (!path) return isTarget ? "Target path is required" : null;
if (path.includes("..")) return "Path cannot contain ..";
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
return null;
};
const validateUrl = (url: string): string | null => {
if (!url) return "Git URL is required";
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
return "Must be a valid git URL (https://, git@, or ssh://)";
}
return null;
};
return (
<div className="git-mount-editor">
<h4 className="section-subtitle">Git Mounts</h4>
{mounts.length > 0 && (
<div className="git-mount-list">
{mounts.map((mount, index) => (
<div key={index} className="git-mount-item">
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
validateUrl={validateUrl}
/>
) : (
<div className="git-mount-display">
<div className="git-mount-info">
<span className="git-mount-repo">{mount.remote_url}</span>
<span className="git-mount-paths">
{mount.source_path || "."} {mount.target_path}
</span>
{mount.branch && (
<span className="git-mount-branch">@{mount.branch}</span>
)}
</div>
<div className="git-mount-actions">
<button
type="button"
className="icon-button"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
<div className="git-mount-add">
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
onSave={handleAdd}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
validateUrl={validateUrl}
isNew
/>
</div>
</div>
);
};
interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
validatePath: (path: string, isTarget: boolean) => string | null;
validateUrl: (url: string) => string | null;
isNew?: boolean;
}
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
const [form, setForm] = useState<GitMount>({ ...mount });
const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (field: keyof GitMount, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
const urlError = validateUrl(form.remote_url);
if (urlError) newErrors.remote_url = urlError;
const sourceError = validatePath(form.source_path || ".", false);
if (sourceError) newErrors.source_path = sourceError;
const targetError = validatePath(form.target_path, true);
if (targetError) newErrors.target_path = targetError;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSave(form);
if (isNew) {
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}
};
return (
<div className="git-mount-form">
<div className="form-row">
<label>Git URL</label>
<input
type="text"
value={form.remote_url}
onChange={(e) => handleChange("remote_url", e.target.value)}
placeholder="https://github.com/user/repo.git"
className={errors.remote_url ? "error" : ""}
/>
<span className="hint">Repository URL (HTTPS or SSH)</span>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
</div>
<div className="form-row">
<label>Source Path</label>
<input
type="text"
value={form.source_path || "."}
onChange={(e) => handleChange("source_path", e.target.value)}
placeholder="e.g., . or configs/*.json"
className={errors.source_path ? "error" : ""}
/>
<span className="hint">Relative path in repo (supports glob patterns)</span>
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
</div>
<div className="form-row">
<label>Target Path</label>
<input
type="text"
value={form.target_path}
onChange={(e) => handleChange("target_path", e.target.value)}
placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""}
/>
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
</div>
<div className="form-row">
<label>Branch (optional)</label>
<input
type="text"
value={form.branch || ""}
onChange={(e) => handleChange("branch", e.target.value)}
placeholder="e.g., main or v1.0"
/>
<span className="hint">Branch or tag to checkout</span>
</div>
<div className="form-actions">
<button type="button" className="primary-button" onClick={handleSubmit}>
{isNew ? "Add" : "Save"}
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
-1
View File
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
@@ -1,5 +1,4 @@
import { useState } from "react";
import { Icon } from "./icon";
interface FormField {
name: string;
@@ -22,8 +22,6 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
onSearch,
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
@@ -21,6 +21,7 @@ import {
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { GitMountEditor } from "../components/git-mount-editor";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
@@ -47,6 +48,7 @@ export const ConfigProfilesPage = () => {
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
@@ -67,6 +69,7 @@ export const ConfigProfilesPage = () => {
setProfiles(profs || []);
setProjects(projs || []);
setToolTypes(types || []);
setStatus("ready");
} catch {
setStatus("error");
@@ -84,6 +87,7 @@ export const ConfigProfilesPage = () => {
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
@@ -102,6 +106,7 @@ export const ConfigProfilesPage = () => {
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
git_mounts: profile.git_mounts || [],
files: profile.files,
is_default: profile.is_default,
});
@@ -1239,6 +1244,13 @@ export const ConfigProfilesPage = () => {
</button>
</div>
<div className="form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
/>
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
-1
View File
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
+3 -3
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
+1 -3
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
@@ -11,7 +10,7 @@ import {
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { getUserConfig, updateUserConfig } from "../api/settings";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card";
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
+150
View File
@@ -4006,3 +4006,153 @@ a.nav-item,
to { transform: translateY(0); }
}
/* Git Mount Editor Styles */
.git-mount-editor {
margin-top: 1rem;
}
.git-mount-editor .section-subtitle {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
}
.git-mount-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.git-mount-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem;
}
.git-mount-display {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.git-mount-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-repo {
font-weight: 600;
color: var(--text);
}
.git-mount-paths {
font-size: 0.875rem;
color: var(--text-muted);
font-family: monospace;
}
.git-mount-branch {
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
width: fit-content;
}
.git-mount-actions {
display: flex;
gap: 0.25rem;
}
.git-mount-add {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 1rem;
}
.git-mount-add h5 {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.git-mount-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.git-mount-form .form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-form .form-row label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.git-mount-form .form-row input,
.git-mount-form .form-row select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.git-mount-form .form-row input.error,
.git-mount-form .form-row select.error {
border-color: #cd3131;
}
.git-mount-form .form-row .hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.git-mount-form .form-row .error-text {
font-size: 0.75rem;
color: #cd3131;
}
.git-mount-form .form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.new-repo-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.new-repo-form input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
color: var(--text);
font-size: 0.875rem;
}
.new-repo-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
+1
View File
@@ -33,6 +33,7 @@ All responses are JSON. Error responses follow this format:
- [Auth](auth.md) - Authentication endpoints
- [Projects](projects.md) - Project management
- [Repositories](repositories.md) - Git repositories and file operations
- [Config Profiles](config-profiles.md) - Config profile management with git mounts
- [Users](users.md) - User management and settings
- [Tool Types](tool-types.md) - Tool type management
- [SSH Keys](ssh-keys.md) - SSH key management
+165
View File
@@ -0,0 +1,165 @@
# Config Profiles
## Overview
Config profiles allow users to define reusable configuration sets for tool instances. Profiles can include environment variables, files, mounts, and git repository mounts. They support profile includes for composition and can be scoped to specific projects or tool types.
## Git Mounts
Git mounts allow you to mount files or directories from git repositories into tool instances at startup.
### Git Mount Object
```json
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": ".",
"target_path": "/app/config",
"branch": "main"
}
```
**Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `repo_id` | string (UUID) | Yes | ID of the git repository to mount from |
| `source_path` | string | No | Path within the repository (default: "."). Supports glob patterns like "*.json" or "configs/**" |
| `target_path` | string | Yes | Absolute path inside the container where files will be mounted |
| `branch` | string | No | Branch or tag to checkout before mounting (default: current branch) |
### Path Validation
- `source_path`: Must be relative (no leading `/`). Cannot contain `..` (path traversal)
- `target_path`: Must be absolute (starts with `/`). Cannot contain `..`
### Glob Patterns
The `source_path` supports standard glob patterns:
- `*.json` - Match all JSON files in root
- `configs/**` - Match all files in configs directory recursively
- `src/*.py` - Match all Python files in src directory
- `.` - Mount entire repository (default)
**Limits:**
- Maximum 100 matches per glob pattern
- Only matches within the repository boundary
### Branch Behavior
When a `branch` is specified:
1. System attempts to checkout the branch in the existing clone
2. If branch doesn't exist locally, attempts to fetch from remote and checkout
3. If checkout fails, logs warning and continues with current branch
4. No branch specified: uses current checked-out branch
**Auto-clone:** If repository is not cloned locally, the system will automatically clone it using the repository's configured SSH key.
## Endpoints
### List Config Profiles
```
GET /config-profiles
```
Query parameters:
- `project_id` (optional): Filter by project compatibility
- `tool_type_id` (optional): Filter by tool type compatibility
Response includes `git_mounts` array in each profile.
### Create Config Profile
```
POST /config-profiles
```
Request body:
```json
{
"name": "My Profile",
"git_mounts": [
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": "configs/*.json",
"target_path": "/app/config",
"branch": "main"
}
]
}
```
Validation:
- All referenced repositories must exist
- Repositories must belong to the same project (if profile has project_id)
- source_path and target_path must pass path validation
### Update Config Profile
```
PUT /config-profiles/{id}
```
Same request body as create. Partial updates supported (omit fields to keep current values).
### Preview Resolved Profile
```
GET /config-profiles/{id}/preview
```
Returns the fully resolved profile with all includes merged. Git mounts from included profiles are merged with override rules (later profiles override earlier ones with same repo_id + target_path combo).
Response:
```json
{
"profile_id": "550e8400-e29b-41d4-a716-446655440000",
"profile_name": "My Profile",
"env_vars": {},
"runtime_hints": {},
"mounts": [],
"git_mounts": [
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": "configs/*.json",
"target_path": "/app/config",
"branch": "main"
}
],
"files": {},
"overrides": {
"env_vars": {},
"runtime_hints": {},
"files": {},
"mounts": {}
},
"included_profiles": []
}
```
## Error Handling
Git mount errors during instance startup are non-blocking:
- Missing repository: Mount skipped, warning logged
- Clone failure: Mount skipped, warning logged
- Invalid paths: Mount skipped, warning logged
- Branch checkout failure: Falls back to current branch, warning logged
Instance startup continues normally even if some git mounts fail.
## Profile Resolution
When a profile includes other profiles, git mounts are merged:
- Same `repo_id` + `target_path` combo: later profile overrides
- Different combos: both are kept
- Branch conflicts: later profile wins
Example:
```
Base Profile: git_mounts = [{repo_a, /app, main}]
Included Profile: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
Resolved: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
```
+67
View File
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
---
## GET /repositories
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
### Response
#### Success (200 OK)
```json
[
{
"id": "uuid",
"name": "my-external-repo",
"remote_url": "https://github.com/user/repo.git",
"is_mirror": false,
"project_id": null,
"owner_id": "uuid",
"created_at": "2024-01-01T00:00:00Z"
}
]
```
---
## POST /repositories
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
### Request
#### Request Body
```json
{
"name": "my-external-repo",
"remote_url": "https://github.com/user/repo.git",
"ssh_key_id": "uuid"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
| `remote_url` | `string` | No | Remote URL to clone from |
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
### Response
#### Success (201 Created)
```json
{
"id": "uuid",
"name": "my-external-repo",
"path": "/data/repos/external/{user_id}/{repo_id}",
"remote_url": "https://github.com/user/repo.git",
"is_mirror": false,
"project_id": null,
"owner_id": "uuid",
"ssh_key_id": "uuid",
"created_at": "2024-01-01T00:00:00Z"
}
```
---
## GET /projects/{project_id}/repositories
**Description:** List repositories in a project.
+159
View File
@@ -0,0 +1,159 @@
# Using Git Repositories in Config Profiles
## Overview
Config profiles now support mounting files and directories from git repositories directly into your tool instances. This is useful for:
- Sharing configuration files across multiple instances
- Mounting dotfiles or development environment configs
- Including shared code or assets from other repositories
- Pinning specific branches or versions of dependencies
## How It Works
When you start a tool instance with a config profile that has git mounts:
1. The system checks if the repository is cloned locally
2. If not cloned and a remote URL is available, it automatically clones the repository
3. If a branch is specified, it checks out that branch
4. Files matching the source path pattern are mounted as bind mounts into the container
5. Instance startup continues normally
## Adding Git Mounts
### Step 1: Select a Repository
In the config profile editor, find the "Git Mounts" section. Choose a repository from the dropdown. Only repositories from your projects are available.
### Step 2: Configure Source Path
The source path determines which files from the repository to mount:
- **`.`** (default): Mount the entire repository
- **`configs/`**: Mount the configs directory
- **`*.json`**: Mount all JSON files in the repository root
- **`src/**/*.py`**: Mount all Python files in the src directory recursively
**Glob patterns are supported** - use `*` for any characters, `**` for recursive matching.
### Step 3: Set Target Path
The target path is where files appear inside the container:
- `/app/config` - Mount to /app/config
- `/home/user/dotfiles` - Mount to user's home directory
- `/workspace/shared` - Mount to workspace shared folder
Target paths must be absolute (start with `/`).
### Step 4: Optional Branch Selection
You can pin a specific branch or tag:
- `main` - Use the main branch
- `develop` - Use the develop branch
- `v1.2.3` - Pin to a specific tag
If not specified, the current checked-out branch is used.
## Examples
### Dotfiles Configuration
Mount your dotfiles repository into the home directory:
```
Repository: dotfiles
Source Path: .
Target Path: /home/user
Branch: main
```
### Shared Configuration Files
Mount only JSON config files from a shared config repo:
```
Repository: shared-configs
Source Path: *.json
Target Path: /app/config
Branch: production
```
### Development Tools Configuration
Mount specific tool configs:
```
Repository: dev-tools
Source Path: vscode/
Target Path: /workspace/.vscode
```
### Multiple Mounts
You can add multiple git mounts to a single profile:
1. Dotfiles → `/home/user`
2. Shared configs → `/app/config`
3. Assets → `/app/static`
## Profile Includes
Git mounts work with profile includes. If Profile A includes Profile B:
- Both profiles' git mounts are merged
- Same repository + target path combinations override (later profile wins)
- Different combinations are kept
Example:
```
Base Profile:
- repo: dotfiles, target: /home/user, branch: main
Development Profile (includes Base):
- repo: dotfiles, target: /home/user, branch: develop
- repo: dev-tools, target: /opt/tools
Resolved Result:
- repo: dotfiles, target: /home/user, branch: develop (overridden)
- repo: dev-tools, target: /opt/tools (added)
```
## Error Handling
Git mounts are non-blocking:
- **Repository not found**: Mount is skipped, instance continues starting
- **Clone fails**: Mount is skipped, warning logged
- **Branch doesn't exist**: Falls back to current branch, warning logged
- **Glob pattern matches nothing**: Mount is skipped, warning logged
- **Path outside repository**: Match is skipped, warning logged
You can check the instance logs to see which mounts succeeded and which failed.
## Best Practices
1. **Use specific paths**: Instead of mounting the entire repository, mount only the files you need. This reduces startup time and avoids conflicts.
2. **Pin branches**: For reproducible environments, pin specific branches or tags rather than using the default branch.
3. **Keep repositories small**: Large repositories take longer to clone. Consider splitting config repositories from code repositories.
4. **Use absolute target paths**: Always use absolute paths (starting with `/`) for target paths to ensure files end up in the expected location.
5. **Test includes**: When using profile includes, use the Preview feature to verify that git mounts are merged as expected.
## Troubleshooting
**Issue**: Git mount not appearing in container
**Solution**: Check instance logs for warnings. Common causes: repository not found, clone failure, or source path not matching any files.
**Issue**: Wrong branch mounted
**Solution**: Verify branch name is correct. If branch doesn't exist locally, the system falls back to the current branch. Ensure the remote has the branch.
**Issue**: Too many files matched
**Solution**: Use more specific glob patterns. The system limits matches to 100 files per glob pattern.
**Issue**: Permission denied
**Solution**: Ensure the target path inside the container is writable. Some paths like `/usr` or `/etc` may require root access.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-26
@@ -0,0 +1,75 @@
## Context
Config profiles currently support inline files and inline mounts, but users cannot reference git repositories. This forces users to either copy-paste file contents or use the generic volume mounts system, which doesn't integrate with the git repository model already present in the system.
Git repositories already have clone, branch, and path management. We need to bridge config profiles with git repositories so users can manage dotfiles and configurations in git and mount them into containers via profiles.
## Goals / Non-Goals
**Goals:**
- Allow config profiles to reference git repositories for file mounting
- Support path mapping (source path in repo → target path in container)
- Support branch/tag pinning for reproducible mounts
- Integrate seamlessly with existing profile resolution and instance startup
- Maintain backward compatibility with existing profiles
**Non-Goals:**
- Manual git clone management by users (system handles cloning automatically)
- Writing back to git repos from containers
- Git merge conflict resolution inside profiles
- Submodules support (out of scope for initial implementation)
## Decisions
### 1. Git Mounts as Separate Field (Not Inline in `mounts`)
**Decision**: Add `git_mounts` as a top-level field on `ConfigProfile`, separate from existing `mounts`.
**Rationale**: Existing `mounts` are inline files staged at instance startup. Git mounts are references to external repositories. Keeping them separate maintains clear semantics and allows independent validation.
### 2. Bind Mount at Instance Startup (Not Copy)
**Decision**: Create bind mounts from the repo filesystem path into the container.
**Rationale**: Bind mounts are immediate and don't require copying files. Changes in the repo are reflected in running containers. Alternative (copying files) would require restaging on every instance start and wouldn't reflect live changes.
### 3. Lazy Repo Validation (Not Strict at Save Time)
**Decision**: Validate that the referenced repository exists when the profile is saved, but don't require the repo to be cloned or the branch to exist.
**Rationale**: Repositories may be created after profiles. The instance startup process will handle missing repos gracefully (log warning, skip mount).
### 4. Single Repo per Mount Entry (Not Multiple)
**Decision**: Each `git_mounts` entry references exactly one repository.
**Rationale**: Simplifies the data model and UI. Users can add multiple entries if they need multiple repos.
### 5. Glob Pattern Support in Source Path
**Decision**: Support glob patterns in `source_path` using standard glob syntax (e.g., `configs/**/*`, `*.sh`).
**Rationale**: Users often want to mount categories of files (all config files, all scripts) without listing them individually. The system will expand globs at instance startup and create individual bind mounts for each matched file.
### 6. Auto-Clone on Instance Startup
**Decision**: If a referenced repository is not cloned when an instance starts, the system automatically clones it using the existing clone service.
**Rationale**: Users should not need to manually manage repository state. The system already has clone logic (SSH keys, branch checkout) that can be reused. Clone happens lazily at first use.
### 7. Git Mounts in Profile Preview
**Decision**: Include resolved git mounts in the profile preview output with repository names, paths, and branch information.
**Rationale**: Users need visibility into what will be mounted before starting an instance. This helps debug configuration issues.
## Risks / Trade-offs
**[Risk] Repository clone failure** → **Mitigation**: Clone is attempted at instance startup with full error logging. If clone fails (e.g., bad SSH key, network issue), a clear error is shown and the mount is skipped.
**[Risk] Glob pattern matches too many files** → **Mitigation**: Limit glob expansion to 100 files per mount. Warn if limit exceeded. Users can use more specific patterns.
**[Risk] Branch/tag may not exist** → **Mitigation**: Instance startup attempts checkout after clone. Falls back to default branch with warning.
**[Risk] Performance impact on instance startup** → **Mitigation**: Git mounts are processed in parallel with other startup steps. Clone only happens once per repo. Subsequent instances reuse existing clone.
**[Trade-off] Bind mounts vs inline files** → Bind mounts don't work across filesystem boundaries (repo must be on same host as Docker). This is acceptable for our single-host deployment model.
## Migration Plan
1. Database migration adds `git_mounts` column (nullable JSONB, default empty list)
2. Existing profiles have `git_mounts: []` and continue to work
3. Frontend UI shows new git mounts section only when editing (not required)
4. No changes needed to running instances
## Decisions Resolved
1. **Glob patterns**: YES - Support standard glob syntax in `source_path`
2. **Profile preview**: YES - Include git mounts in preview/resolve output
3. **Auto-clone**: YES - System clones repos automatically, no user reliance
@@ -0,0 +1,31 @@
## Why
Config profiles currently only support inline file content, which is impractical for dotfiles and configuration repositories that users manage with git. Users need a way to include files from git repositories (similar to yadm) so they can version-control their dotfiles and mount them into containers at startup.
## What Changes
- Add `git_mounts` field to config profiles, allowing references to git repositories
- Mount specific paths from git repositories into containers at configurable target paths
- Support branch/tag selection for reproducible mounts
- Integrate with existing profile resolution and instance startup pipeline
- Update config profile UI to manage git repository mounts alongside existing mounts
- **No breaking changes** - existing profiles continue to work unchanged
## Capabilities
### New Capabilities
- `config-profile-git-mounts`: Mounting files from git repositories into containers via config profiles, including repo selection, path mapping, and branch pinning
### Modified Capabilities
- `tool-instances`: Instance startup pipeline now processes git mounts from resolved profiles before container creation
- `git-repo`: Repository model may need branch/tag listing for mount configuration
## Impact
- **Backend**: `apps/api/src/models/config_profile.py` - add git_mounts field
- **Backend**: `apps/api/src/services/config_profile_resolver.py` - resolve git mounts in profile resolution
- **Backend**: `apps/api/src/services/docker.py` or instance startup - bind mount from repo path to container
- **Backend**: `apps/api/src/api/config_profiles.py` - CRUD for git mounts
- **Frontend**: `apps/web/src/pages/config-profiles.tsx` - UI for managing git mounts
- **Frontend**: `apps/web/src/api/config_profiles.ts` - API types for git mounts
- **Database**: Migration to add git_mounts column to config_profiles table
@@ -0,0 +1,149 @@
## ADDED Requirements
### Requirement: Config profiles can reference git repositories for file mounting
The system SHALL allow config profiles to include git repository mounts that bind repository paths into containers.
#### Scenario: Create profile with git mount
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
- **THEN** the profile stores each git mount with:
- `remote_url`: Direct git URL (e.g., "https://github.com/user/repo.git", "git@github.com:user/repo.git")
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
- `target_path`: Absolute path inside the container (e.g., "/home/user")
- `branch`: Optional branch or tag name (defaults to "main")
#### Scenario: Git mount validation
- **WHEN** a profile with git mounts is saved
- **THEN** the system validates that:
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
- `source_path` is a relative path (no leading `/`)
- `target_path` can be absolute (starts with `/`) or relative (resolved against working directory, defaulting to `/home/user`)
- `target_path` does not contain path traversal sequences (`..`)
- No database lookup or repository existence check is performed (validation is deferred to clone time)
#### Scenario: Profile with git mounts is resolved
- **GIVEN** a config profile with git mounts
- **WHEN** the profile is resolved for instance startup
- **THEN** the resolved profile includes the git mounts as configured
- **AND** repository cloning happens at instance startup time, not at profile resolution
#### Scenario: Git mount is applied at instance startup
- **GIVEN** a resolved profile with git mounts
- **WHEN** an instance is started with this profile
- **THEN** for each git mount:
- The repository is cloned from `remote_url` to a temporary location
- The source path within the cloned repository exists
- A bind mount is created from `clone_path/source_path` to `container:target_path`
- **AND** if the clone fails or path is missing, a warning is logged and the mount is skipped
### Requirement: Git mounts support glob patterns
The system SHALL support glob patterns in `source_path` for matching multiple files.
#### Scenario: Mount files matching glob pattern
- **GIVEN** a git mount with `source_path: "configs/**/*.json"`
- **WHEN** the instance is started
- **THEN** the system expands the glob pattern within the repository
- **AND** creates individual bind mounts for each matched file
- **AND** preserves directory structure relative to `target_path`
#### Scenario: Glob pattern matches nothing
- **GIVEN** a git mount with `source_path: "nonexistent/**/*"`
- **WHEN** the instance is started
- **THEN** the system logs a warning that no files matched the pattern
- **AND** the mount is skipped
#### Scenario: Glob pattern limit exceeded
- **GIVEN** a git mount with `source_path: "**/*"` matching 500 files
- **WHEN** the instance is started
- **THEN** the system limits expansion to 100 files
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
### Requirement: Git mounts trigger automatic cloning
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
#### Scenario: Repository cloned on container creation
- **GIVEN** a git mount with a `remote_url`
- **WHEN** a new container is created with this profile
- **THEN** the system clones the repository from the URL to an instance-specific directory
- **AND** the clone proceeds as part of instance startup
- **AND** instance startup continues once clone completes
#### Scenario: Existing clone updated on new container creation
- **GIVEN** a repository that was previously cloned for this instance
- **WHEN** a new container is created with this profile
- **THEN** the system pulls the latest updates from the remote_url
- **AND** checks out the specified branch (or default branch if not specified)
- **AND** uses the updated clone for the bind mount
#### Scenario: Clone failure handling
- **GIVEN** a git mount referencing a repository with an invalid SSH key
- **WHEN** the instance attempts to clone
- **THEN** the clone operation fails
- **AND** an error is logged with details
- **AND** the mount is skipped
- **AND** instance startup continues with remaining mounts
#### Scenario: Per-instance isolation
- **GIVEN** a git mount referencing a repository
- **WHEN** multiple instances are created using the same profile
- **THEN** each instance gets its own independent clone
- **AND** changes made in one container do not affect other containers
### Requirement: Git mounts support branch pinning
The system SHALL support pinning git mounts to specific branches or tags.
#### Scenario: Mount specific branch
- **GIVEN** a git mount with `branch: "develop"`
- **WHEN** the instance is started
- **THEN** the system attempts to checkout the "develop" branch in the repository
- **AND** the bind mount uses the files from the checked-out branch
#### Scenario: Branch fallback to default
- **GIVEN** a git mount with `branch: "nonexistent"`
- **WHEN** the instance is started
- **THEN** the system logs a warning that the branch does not exist
- **AND** falls back to the repository's current/default branch
- **AND** the bind mount proceeds with the fallback branch
### Requirement: Git mounts are visible in profile UI
The system SHALL display git mounts in the config profile editor.
#### Scenario: View git mounts in profile editor
- **GIVEN** a config profile with git mounts
- **WHEN** the user views the profile in the UI
- **THEN** the git mounts section displays each mount with:
- Git URL
- Source path within repository
- Target path in container
- Branch/tag (if specified)
#### Scenario: Add git mount via UI
- **WHEN** a user adds a git mount in the profile editor
- **THEN** they can:
- Enter a git URL directly (https://, git@, or ssh://)
- Specify the source path (with autocomplete or validation)
- Specify the target path in the container
- Optionally enter a branch/tag name
#### Scenario: Remove git mount via UI
- **WHEN** a user removes a git mount from the profile editor
- **THEN** the mount is removed from the profile
- **AND** existing instances using this profile are unaffected
### Requirement: Git mounts are visible in profile preview
The system SHALL include git mounts in the profile preview/resolve output.
#### Scenario: Preview shows git mount details
- **GIVEN** a config profile with git mounts
- **WHEN** the user requests a profile preview
- **THEN** the preview includes a "git_mounts" section showing:
- Repository name and URL
- Source path (with expanded glob matches if applicable)
- Target path in container
- Resolved branch name
- Clone status (will clone on container creation)
#### Scenario: Preview warns about missing repository
- **GIVEN** a config profile with a git mount referencing a non-existent repository
- **WHEN** the user requests a profile preview
- **THEN** the preview shows a warning: "Repository [name] not found"
- **AND** indicates that the mount will be skipped at startup
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Instance startup processes git mounts from config profiles
The system SHALL process git repository mounts from resolved config profiles during instance startup.
#### Scenario: Instance startup with git mounts
- **GIVEN** a tool instance configured with a profile that has git mounts
- **WHEN** the instance starts
- **THEN** the startup pipeline:
1. Resolves the config profile (including inherited profiles)
2. Collects all git mounts from the resolved profile
3. For each git mount, verifies the repository filesystem path exists
4. Creates bind mount entries in the compose file for each valid git mount
5. Logs warnings for any invalid or missing git mounts without failing startup
#### Scenario: Git mount bind mount creation
- **GIVEN** a resolved git mount with:
- repository filesystem path: `/data/repos/user/project/dotfiles`
- source path: `.`
- target path: `/home/user`
- **WHEN** the instance compose file is generated
- **THEN** a volume entry is added:
```yaml
volumes:
- /data/repos/user/project/dotfiles:/home/user:ro
```
- **AND** the mount is read-only by default
#### Scenario: Repository auto-clone on startup
- **GIVEN** a git mount referencing a repository that has not been cloned
- **WHEN** the instance starts
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
- **AND** the system waits for clone completion before proceeding
- **AND** the bind mount is created from the cloned repository path
#### Scenario: Clone failure handling
- **GIVEN** a git mount referencing a repository with an invalid SSH key
- **WHEN** the instance attempts to clone during startup
- **THEN** the clone operation fails
- **AND** an error is logged with details
- **AND** the mount is skipped
- **AND** instance startup continues with remaining mounts
- **AND** the instance status is not affected
@@ -0,0 +1,82 @@
## 1. Database and Models
- [x] 1.1 Create Alembic migration to add `git_mounts` column to `config_profiles` table (JSONB, nullable, default empty list)
- [x] 1.2 Update `ConfigProfile` SQLAlchemy model to include `git_mounts` field
- [x] 1.3 Create Pydantic models for GitMount and GitMountCreate schemas with validation
- [x] 1.4 Add validation for git mount fields (source_path relative or glob, target_path absolute, no path traversal)
## 2. Backend API
- [x] 2.1 Update `POST /config-profiles` endpoint to accept `git_mounts` in request body
- [x] 2.2 Update `PUT /config-profiles/{id}` endpoint to accept `git_mounts` updates
- [x] 2.3 Update config profile response schemas to include `git_mounts` in output
- [x] 2.4 Add validation that referenced repositories exist in the same project
- [x] 2.5 Update `GET /config-profiles/{id}/preview` to include resolved git mounts
## 3. Profile Resolution
- [x] 3.1 Update `config_profile_resolver.py` to include git mounts in resolved profile output
- [x] 3.2 Ensure git mounts from included profiles are merged (with override rules)
- [x] 3.3 Add tests for profile resolution with git mounts
## 4. Instance Startup Integration
- [x] 4.1 Modify instance startup pipeline to process git mounts from resolved profile
- [x] 4.2 Add helper function to clone repository if not present (reusing existing clone service)
- [x] 4.3 Add glob pattern expansion for source_path (using standard glob library)
- [x] 4.4 Add helper function to checkout specified branch after clone
- [x] 4.5 Generate bind mount entries in compose file for each valid git mount
- [x] 4.6 Add error logging for missing repos (non-blocking, mount skipped)
- [x] 4.7 Ensure git mounts are processed in parallel with other startup steps
## 5. Frontend Types and API
- [x] 5.1 Update TypeScript types in `apps/web/src/api/config_profiles.ts` to include GitMount interface
- [x] 5.2 Update API client functions to include git_mounts in create/update payloads
- [x] 5.3 Add validation helpers for git mount form fields
## 6. Frontend UI
- [x] 6.1 Add "Git Mounts" section to config profile editor (below existing mounts)
- [x] 6.2 Create GitMountEditor component with repo selector, source/target path inputs (with glob hint), branch selector
- [x] 6.3 Add "Add Git Mount" button that opens the editor
- [x] 6.4 Display existing git mounts with edit/delete actions
- [x] 6.5 Integrate git mounts into profile save flow (include in form submission)
- [x] 6.6 Add validation feedback in UI (repo exists, paths valid, branch exists)
## 7. Testing and Verification
- [x] 7.1 Backend unit tests for git mount validation
- [x] 7.2 Backend integration tests for profile CRUD with git mounts
- [x] 7.3 Test instance startup with git mounts (verify bind mounts created)
- [x] 7.4 Test auto-clone behavior (clone triggered, mount created)
- [x] 7.5 Test clone failure handling (error logged, mount skipped, startup continues)
- [x] 7.6 Test glob pattern expansion (files matched, limit enforced)
- [x] 7.7 Test branch checkout behavior (success and fallback)
- [x] 7.8 Frontend type check passes
- [x] 7.9 Frontend production build succeeds
- [x] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
## 8. Documentation
- [x] 8.1 Update API documentation with new git_mounts fields
- [x] 8.2 Add user guide section for using git repositories in config profiles
- [x] 8.3 Document branch pinning behavior and fallback rules
## 9. External Repository Support
- [x] 9.1 Remove project requirement from git mount validation
- [x] 9.2 Add endpoint to create external repositories (no project_id)
- [x] 9.3 Update list_repositories endpoint to return all user repos
- [x] 9.4 Add endpoint to list external repositories
- [x] 9.5 Update spec: repos can be external (not tied to project)
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
- [x] 9.7 Update spec: pull updates when creating new containers
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
## 10. UI Improvements
- [x] 10.1 Add ability to create external repositories from git mount editor
- [x] 10.2 Show "+ Add new repository..." option in repo dropdown
- [x] 10.3 Add form fields for repo name and remote URL
- [x] 10.4 Auto-refresh repo list after creating new repository
+52
View File
@@ -0,0 +1,52 @@
# Tool Images
This directory contains Dockerfile templates for the base tool types used in Headquarter.
## Available Images
| File | Tool | Description |
|------|------|-------------|
| `base.dockerfile` | - | Common base with git, nvim, ranger, tmux, node |
| `code-server.dockerfile` | VS Code | Browser-based VS Code with extra tools |
| `jupyter.dockerfile` | Jupyter | Jupyter Lab/Notebook with extra tools |
| `opencode.dockerfile` | OpenCode | OpenCode agent server |
| `pi-agent.dockerfile` | Pi Agent | Pi coding agent with full terminal setup |
## Usage
When creating a tool type in the Tool Workshop, you can reference these Dockerfiles:
1. Copy the contents of the desired `.dockerfile`
2. Paste into the "Dockerfile Template" field
3. Set `definition_type` to `dockerfile`
## Building Locally
To test an image locally:
```bash
cd tool-images
docker build -f pi-agent.dockerfile -t pi-agent:latest .
docker run -it pi-agent:latest
```
## Customizing
All images include:
- **git** - Version control
- **neovim** - Terminal editor
- **ranger** - Terminal file manager
- **tmux** - Terminal multiplexer
- **htop** - Process viewer
- **tree** - Directory tree
- **jq** - JSON processor
- **Node.js 20** - For npm-based tools
The `pi-agent` image additionally includes the [Pi Coding Agent](https://pi.dev/) for AI-assisted development.
## Adding New Images
1. Create a new `.dockerfile` in this directory
2. Use `base.dockerfile` as a starting point if applicable
3. Document it in the table above
4. Update the tool type in the database via the Tool Workshop
+36
View File
@@ -0,0 +1,36 @@
# Base development image with common tools
FROM ubuntu:24.04
# Prevent interactive prompts during apt install
ENV DEBIAN_FRONTEND=noninteractive
# Install base tools
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
neovim \
ranger \
tmux \
htop \
tree \
jq \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -m -s /bin/bash user
WORKDIR /home/user
# Install Node.js (needed for pi and many dev tools)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Set up git configuration defaults
RUN git config --global init.defaultBranch main \
&& git config --global user.email "dev@headquarter.local" \
&& git config --global user.name "Developer"
USER user
CMD ["/bin/bash"]
+28
View File
@@ -0,0 +1,28 @@
# VS Code in browser
FROM lscr.io/linuxserver/code-server:latest
USER root
# Install additional tools
RUN apt-get update && apt-get install -y \
git \
neovim \
ranger \
tmux \
htop \
tree \
jq \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Set up git
RUN git config --global init.defaultBranch main
# Code-server runs as abc user by default
USER abc
EXPOSE 8443
+23
View File
@@ -0,0 +1,23 @@
# Jupyter Notebook/Lab
FROM jupyter/scipy-notebook:latest
USER root
# Install additional tools
RUN apt-get update && apt-get install -y \
git \
neovim \
ranger \
tmux \
htop \
tree \
jq \
&& rm -rf /var/lib/apt/lists/*
# Set up git
RUN git config --global init.defaultBranch main
# Switch back to jovyan user (default for scipy-notebook)
USER ${NB_UID}
EXPOSE 8888
+40
View File
@@ -0,0 +1,40 @@
# OpenCode agent environment
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install base dependencies
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
neovim \
ranger \
tmux \
htop \
tree \
jq \
ca-certificates \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install OpenCode
RUN npm install -g opencode
# Create non-root user
RUN useradd -m -s /bin/bash user
WORKDIR /home/user
# Set up git
RUN git config --global init.defaultBranch main
USER user
EXPOSE 3000
CMD ["opencode", "server"]
+53
View File
@@ -0,0 +1,53 @@
# Pi Coding Agent - Terminal-based coding harness
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install base dependencies
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
neovim \
ranger \
tmux \
htop \
tree \
jq \
ca-certificates \
python3 \
python3-pip \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js (required for Pi)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# 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
WORKDIR /home/user
# Set up git
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 echo 'set -g mouse on\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \
&& echo 'set preview_files true\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
USER user
# Default to bash (Pi is invoked manually via `pi` command)
CMD ["/bin/bash"]