Compare commits

...

27 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
68 changed files with 2076 additions and 1188 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'")
)
@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_26_add_git_mounts"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
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
@@ -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))
+17 -45
View File
@@ -12,7 +12,6 @@ from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
from src.services.config_profile_resolver import (
@@ -58,18 +57,16 @@ def _calculate_profile_size(data: dict) -> int:
class GitMountItem(BaseModel):
repo_id: str = Field(description="UUID of the git repository")
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("repo_id")
@field_validator("remote_url")
@classmethod
def validate_repo_id(cls, v: str) -> str:
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid repo_id UUID: {v}")
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")
@@ -84,8 +81,6 @@ class GitMountItem(BaseModel):
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("target_path must be absolute (start with /)")
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@@ -271,46 +266,23 @@ async def _validate_git_mounts(
git_mounts: list[dict],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate that all referenced git repositories exist and are accessible.
"""Validate git mount URLs.
Repositories must:
1. Exist
2. Belong to the user
3. If project_id is specified, belong to that project
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
repo_id = mount.get("repo_id")
if not repo_id:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing repo_id",
detail="Git mount missing remote_url",
)
try:
repo_uuid = uuid.UUID(repo_id)
except ValueError:
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid repo_id UUID: {repo_id}",
)
repo = await session.get(GitRepository, repo_uuid)
if repo is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Git repository not found: {repo_id}",
)
if repo.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to access repository: {repo_id}",
)
if project_id is not None and repo.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Repository {repo_id} does not belong to project {project_id}",
detail=f"Invalid git URL: {remote_url}",
)
@@ -447,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)
@@ -548,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)
@@ -568,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
@@ -653,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
+1 -1
View File
@@ -41,7 +41,7 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
) # {"rel/path": "content", ...}
git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"repo_id": "uuid", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
) # [{"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)
@@ -176,13 +176,13 @@ def _merge_git_mounts(
) -> list[dict[str, Any]]:
"""Merge git mounts from included profiles.
Later mounts override earlier ones with the same repo_id + target_path combo.
Later mounts override earlier ones with the same remote_url + target_path combo.
"""
result = list(base)
# Build lookup by (repo_id, target_path)
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
# 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["repo_id"], mount["target_path"])
key = (mount["remote_url"], mount["target_path"])
if key in seen:
result[seen[key]] = dict(mount)
else:
+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:
+1 -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
@@ -333,7 +333,7 @@ class TestConfigProfilesAPI:
"files": {},
"git_mounts": [
{
"repo_id": repo_id,
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
@@ -369,7 +369,7 @@ class TestConfigProfilesAPI:
json={
"git_mounts": [
{
"repo_id": repo_id,
"remote_url": "https://github.com/user/repo.git",
"source_path": "config",
"target_path": "/config",
}
@@ -393,7 +393,7 @@ class TestConfigProfilesAPI:
"files": {},
"git_mounts": [
{
"repo_id": repo_id,
"remote_url": "https://github.com/user/repo.git",
"source_path": "/absolute/path",
"target_path": "/app",
}
@@ -402,8 +402,8 @@ class TestConfigProfilesAPI:
)
assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount target paths are rejected."""
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(
@@ -414,9 +414,9 @@ class TestConfigProfilesAPI:
"files": {},
"git_mounts": [
{
"repo_id": repo_id,
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "relative/path",
"target_path": "../../../etc/passwd",
}
],
},
@@ -436,7 +436,7 @@ class TestConfigProfilesAPI:
"files": {},
"git_mounts": [
{
"repo_id": repo_id,
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
}
@@ -450,4 +450,4 @@ class TestConfigProfilesAPI:
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["repo_id"] == repo_id
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,7 +6,6 @@ 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,
@@ -63,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"}}],
@@ -102,18 +100,18 @@ class TestMergeFunctions:
"""Test basic git mount merging."""
result = _merge_git_mounts(
[],
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
"source",
)
assert len(result) == 1
assert result[0]["repo_id"] == "repo1"
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(
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"repo_id": "repo1", "source_path": "src", "target_path": "/app", "branch": "dev"}],
[{"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
@@ -123,8 +121,8 @@ class TestMergeFunctions:
def test_merge_git_mounts_different_targets(self) -> None:
"""Test that git mounts with different targets are preserved."""
result = _merge_git_mounts(
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
[{"repo_id": "repo2", "source_path": ".", "target_path": "/config"}],
[{"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
@@ -296,7 +294,7 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(profile)
@@ -304,7 +302,7 @@ class TestResolveProfile:
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
assert result.git_mounts[0]["repo_id"] == "repo1"
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result.git_mounts[0]["target_path"] == "/app"
@pytest.mark.asyncio
@@ -320,7 +318,7 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(base)
@@ -333,7 +331,7 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"repo_id": "repo2", "source_path": "config", "target_path": "/config"},
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
],
)
db_session.add(child)
@@ -1,7 +1,6 @@
"""Unit tests for git mount resolution in tool instances."""
import os
import tempfile
from pathlib import Path
import pytest
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
_expand_glob_source,
_resolve_single_git_mount,
)
from src.services.config_profile_resolver import ResolvedProfile
class TestExpandGlobSource:
@@ -91,23 +89,22 @@ class TestCheckoutBranch:
assert result == "feature"
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
"""Test checking out a non-existent branch raises error."""
"""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'")
with pytest.raises(RuntimeError, match="Failed to checkout branch"):
_checkout_branch(str(tmp_path), "nonexistent")
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_repo(self, db_session) -> None:
"""Test that missing repo returns empty list."""
async def test_resolve_missing_remote_url(self, db_session) -> None:
"""Test that missing remote_url returns empty list."""
git_mount = {
"repo_id": "12345678-1234-1234-1234-123456789abc",
"source_path": ".",
"target_path": "/app",
}
@@ -119,21 +116,9 @@ class TestResolveSingleGitMount:
async def test_resolve_missing_target_path(self, db_session) -> None:
"""Test that missing target path returns empty list."""
git_mount = {
"repo_id": "12345678-1234-1234-1234-123456789abc",
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@pytest.mark.asyncio
async def test_resolve_invalid_repo_id(self, db_session) -> None:
"""Test that invalid repo_id returns empty list."""
git_mount = {
"repo_id": "not-a-uuid",
"source_path": ".",
"target_path": "/app",
}
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,
+1 -1
View File
@@ -25,7 +25,7 @@ export interface ConfigProfileMount {
}
export interface GitMount {
repo_id: string;
remote_url: string;
source_path: string;
target_path: string;
branch?: string;
+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);
}
+34 -37
View File
@@ -1,27 +1,24 @@
import { useState } from "react";
import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles";
import type { GitRepository } from "../api/git_repositories";
interface GitMountEditorProps {
mounts: GitMount[];
repositories: GitRepository[];
onChange: (mounts: GitMount[]) => void;
}
export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => {
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
repo_id: "",
remote_url: "",
source_path: ".",
target_path: "",
branch: "",
});
const handleAdd = () => {
if (!newMount.repo_id || !newMount.target_path) return;
onChange([...mounts, { ...newMount }]);
setNewMount({ repo_id: "", 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) => {
@@ -38,11 +35,18 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
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 "Target path must be absolute";
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>
@@ -54,17 +58,15 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
{editingIndex === index ? (
<GitMountForm
mount={mount}
repositories={repositories}
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">
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
</span>
<span className="git-mount-repo">{mount.remote_url}</span>
<span className="git-mount-paths">
{mount.source_path || "."} {mount.target_path}
</span>
@@ -101,10 +103,10 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
repositories={repositories}
onSave={handleAdd}
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
validateUrl={validateUrl}
isNew
/>
</div>
@@ -114,14 +116,14 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
interface GitMountFormProps {
mount: GitMount;
repositories: GitRepository[];
onSave: (mount: GitMount) => void;
onCancel: () => void;
validatePath: (path: string, isTarget: boolean) => string | null;
validateUrl: (url: string) => string | null;
isNew?: boolean;
}
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => {
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
const [form, setForm] = useState<GitMount>({ ...mount });
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -139,9 +141,8 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
if (!form.repo_id) {
newErrors.repo_id = "Repository is required";
}
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;
@@ -156,27 +157,23 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
onSave(form);
if (isNew) {
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}
};
return (
<div className="git-mount-form">
<div className="form-row">
<label>Repository</label>
<select
value={form.repo_id}
onChange={(e) => handleChange("repo_id", e.target.value)}
className={errors.repo_id ? "error" : ""}
>
<option value="">Select a repository...</option>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
<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">
@@ -201,7 +198,7 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""}
/>
<span className="hint">Absolute path inside container</span>
<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>
@@ -226,4 +223,4 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
</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
+1 -16
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";
@@ -19,7 +19,6 @@ import {
type ResolvedProfile,
} from "../api/config_profiles";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import type { Project } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { GitMountEditor } from "../components/git-mount-editor";
@@ -34,7 +33,6 @@ export const ConfigProfilesPage = () => {
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -72,18 +70,6 @@ export const ConfigProfilesPage = () => {
setProjects(projs || []);
setToolTypes(types || []);
// Load repositories from all projects
const allRepos: GitRepository[] = [];
for (const project of projs || []) {
try {
const repos = await listRepositories(project.id);
allRepos.push(...repos);
} catch {
// Skip projects we can't access
}
}
setRepositories(allRepos);
setStatus("ready");
} catch {
setStatus("error");
@@ -1261,7 +1247,6 @@ export const ConfigProfilesPage = () => {
<div className="form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
repositories={repositories}
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
/>
</div>
-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>>({});
+25
View File
@@ -4131,3 +4131,28 @@ a.nav-item,
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;
}
+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.
@@ -6,35 +6,34 @@ The system SHALL allow config profiles to include git repository mounts that bin
#### 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:
- `repo_id`: UUID of the referenced git repository
- `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 repository default branch)
- `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:
- The referenced repository exists and belongs to the user's project
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
- `source_path` is a relative path (no leading `/`)
- `target_path` is an absolute path (starts with `/`)
- `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 referencing repository "dotfiles"
- **GIVEN** a config profile with git mounts
- **WHEN** the profile is resolved for instance startup
- **THEN** the resolved profile includes the git mounts with repository details:
- Repository filesystem path
- Resolved branch name
- Source and target paths
- **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 filesystem path exists
- The source path within the repository exists
- A bind mount is created from `repo_path/source_path` to `container:target_path`
- **AND** if the repository or path is missing, a warning is logged and the mount is skipped
- 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.
@@ -59,15 +58,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
- **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 if they do not exist locally.
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 not cloned at startup
- **GIVEN** a git mount referencing a repository that has not been cloned
- **WHEN** the instance is started
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
- **AND** the clone proceeds asynchronously
#### 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
@@ -76,6 +82,12 @@ The system SHALL automatically clone referenced repositories if they do not exis
- **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.
@@ -99,7 +111,7 @@ The system SHALL display git mounts in the config 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:
- Repository name
- Git URL
- Source path within repository
- Target path in container
- Branch/tag (if specified)
@@ -107,10 +119,10 @@ The system SHALL display git mounts in the config profile editor.
#### Scenario: Add git mount via UI
- **WHEN** a user adds a git mount in the profile editor
- **THEN** they can:
- Select from available repositories in the project
- 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 select a branch/tag
- Optionally enter a branch/tag name
#### Scenario: Remove git mount via UI
- **WHEN** a user removes a git mount from the profile editor
@@ -128,7 +140,7 @@ The system SHALL include git mounts in the profile preview/resolve output.
- Source path (with expanded glob matches if applicable)
- Target path in container
- Resolved branch name
- Clone status (exists, will clone, clone failed)
- 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
@@ -55,10 +55,28 @@
- [x] 7.7 Test branch checkout behavior (success and fallback)
- [x] 7.8 Frontend type check passes
- [x] 7.9 Frontend production build succeeds
- [ ] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
- [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"]