Compare commits

...

14 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
58 changed files with 1581 additions and 981 deletions
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0014_merge_heads" revision: str = "0014_merge_heads"
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0015_single_interface" 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'")
)
@@ -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 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 typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a" revision: str = "f3d2dc90ba3a"
+10 -10
View File
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
state=state, 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 = RedirectResponse(location)
response.set_cookie("auth_state", state, httponly=True, samesite="lax") response.set_cookie("auth_state", state, httponly=True, samesite="lax")
response.set_cookie("auth_next", next, 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="/"), auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse: ) -> 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: if auth_state is None or auth_state != state:
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state) logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
@@ -71,7 +71,7 @@ async def callback(
settings = Settings() settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback" 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: async with httpx.AsyncClient() as client:
try: try:
@@ -92,7 +92,7 @@ async def callback(
access_token=token_payload["access_token"], access_token=token_payload["access_token"],
client=client, client=client,
) )
logger.info("User info fetched successfully") logger.debug("User info fetched successfully")
except Exception as exc: except Exception as exc:
logger.error("User info fetch failed: %s", 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") 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", "")) authentik_id = str(user_info.get("sub", ""))
email = str(user_info.get("email", f"{authentik_id}@authentik.local")) email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
name = str(user_info.get("name", email)) 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: try:
user = await session.scalar(select(User).where(User.authentik_id == authentik_id)) user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None: 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) user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user) session.add(user)
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
logger.info("New user created: id=%s", user.id) logger.info("New user created: id=%s", user.id)
else: 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.email = email
user.name = name user.name = name
await session.commit() await session.commit()
@@ -165,20 +165,20 @@ async def me(
session_cookie: str | None = Cookie(default=None, alias="session"), session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]: ) -> 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: if not session_cookie:
logger.warning("Auth /me: missing session cookie") logger.warning("Auth /me: missing session cookie")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings() 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) settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
try: try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie) payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = payload["user_id"] 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: except ValueError as exc:
logger.warning("Auth /me: invalid session: %s", exc) logger.warning("Auth /me: invalid session: %s", exc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
+4 -7
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.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.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
@@ -82,8 +81,6 @@ class GitMountItem(BaseModel):
@field_validator("target_path") @field_validator("target_path")
@classmethod @classmethod
def validate_target_path(cls, v: str) -> str: def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("target_path must be absolute (start with /)")
if ".." in v: if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)") raise ValueError("target_path cannot contain path traversal (..)")
return v return v
@@ -422,7 +419,7 @@ async def create_config_profile(
) )
profile = result.scalar_one() 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) return _profile_to_response(profile)
@@ -523,7 +520,7 @@ async def update_config_profile(
) )
profile = result.scalar_one() 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) return _profile_to_response(profile)
@@ -543,7 +540,7 @@ async def delete_config_profile(
await session.delete(profile) await session.delete(profile)
await session.commit() await session.commit()
logger.info("Deleted config profile %s", profile_id) logger.debug("Deleted config profile %s", profile_id)
return None return None
@@ -628,7 +625,7 @@ async def update_profile_includes(
) )
direct_includes = inc_result.scalars().all() 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)) return _profile_to_response(profile, list(direct_includes))
-2
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.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
get_file_content, get_file_content,
+1 -2
View File
@@ -4,11 +4,10 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, status from fastapi import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal from src.database import SessionLocal
router = APIRouter() router = APIRouter()
-1
View File
@@ -2,7 +2,6 @@
import logging import logging
import uuid import uuid
from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status 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.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
-1
View File
@@ -1,6 +1,5 @@
"""Shared Pydantic validators for API schemas.""" """Shared Pydantic validators for API schemas."""
from typing import Any
MAX_FOLDER_SIZE_MB = 10 MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024 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.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
+13 -13
View File
@@ -4,7 +4,7 @@ import asyncio
import logging import logging
import uuid 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 sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
@@ -44,9 +44,9 @@ async def terminal_websocket(
Returns: Returns:
None. Communicates via WebSocket messages. 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() await websocket.accept()
logger.info("Terminal WebSocket accepted for instance %s", instance_id) logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
try: try:
# Parse instance_id # Parse instance_id
@@ -80,13 +80,13 @@ async def terminal_websocket(
await websocket.close(code=4004, reason="Instance not running") await websocket.close(code=4004, reason="Instance not running")
return 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 # Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id) tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None startup_command = tool_type.startup_command if tool_type else None
if startup_command: 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 # Get or create terminal session
try: try:
@@ -95,15 +95,15 @@ async def terminal_websocket(
instance.container_id, instance.container_id,
startup_command=startup_command, 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 # Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket) 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 # Send connected status
await websocket.send_json({"type": "status", "status": "connected"}) 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 # Use mutable session reference so loops can survive reset
session_ref = SessionRef(session) session_ref = SessionRef(session)
@@ -112,7 +112,7 @@ async def terminal_websocket(
read_task = asyncio.create_task(_read_loop(session_ref, websocket)) read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id)) write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) 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) # Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
@@ -120,7 +120,7 @@ async def terminal_websocket(
return_when=asyncio.FIRST_COMPLETED, 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 # Cancel remaining tasks
for task in pending: for task in pending:
@@ -134,7 +134,7 @@ async def terminal_websocket(
try: try:
if 'session' in locals(): if 'session' in locals():
await terminal_manager.detach_websocket(session, websocket) 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: except Exception:
pass pass
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
if msg_type == "resize": if msg_type == "resize":
cols = ctrl.get("cols", 80) cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24) 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) await session.resize(cols, rows)
elif msg_type == "reset": elif msg_type == "reset":
# Reset terminal session # 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"}) await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session # Reset the session
+1 -1
View File
@@ -1,6 +1,5 @@
"""Tool configuration API endpoints.""" """Tool configuration API endpoints."""
import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status 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.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.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
+378 -129
View File
@@ -1,5 +1,7 @@
"""Tool instance API endpoints.""" """Tool instance API endpoints."""
import asyncio
import glob as glob_module
import logging import logging
import os import os
import subprocess import subprocess
@@ -7,23 +9,39 @@ import uuid
from datetime import datetime from datetime import datetime
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import (
from fastapi.responses import StreamingResponse APIRouter,
APIRouter as FastAPIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__) from src.auth.dependencies import (
_get_owned_project,
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session _get_user,
get_current_user_id,
get_db_session,
)
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ResolvedProfile,
apply_resolved_profile,
resolve_profile,
)
from src.services.docker import ( from src.services.docker import (
check_tunnel_health, check_tunnel_health,
connect_container_to_network, connect_container_to_network,
@@ -43,24 +61,18 @@ from src.services.docker import (
write_config_files, write_config_files,
write_env_file, write_env_file,
) )
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
from src.services.docker_build import build_image from src.services.docker_build import build_image
from src.services.config_profile_resolver import (
apply_resolved_profile,
resolve_profile,
ConfigProfileCycleError,
ResolvedProfile,
)
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
logger = logging.getLogger(__name__)
import asyncio
import glob as glob_module
async def _resolve_git_mounts( async def _resolve_git_mounts(
session: AsyncSession, session: AsyncSession,
resolved: ResolvedProfile, resolved: ResolvedProfile,
instance_dir: str | None = None, instance_dir: str | None = None,
working_directory: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Convert git mounts from resolved profile to Docker volume mounts. """Convert git mounts from resolved profile to Docker volume mounts.
@@ -74,7 +86,11 @@ async def _resolve_git_mounts(
# Process all git mounts concurrently # Process all git mounts concurrently
tasks = [] tasks = []
for git_mount in resolved.git_mounts: for git_mount in resolved.git_mounts:
tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir)) tasks.append(
_resolve_single_git_mount(
session, git_mount, instance_dir, working_directory
)
)
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -93,6 +109,7 @@ async def _resolve_single_git_mount(
session: AsyncSession, session: AsyncSession,
git_mount: dict, git_mount: dict,
instance_dir: str | None = None, instance_dir: str | None = None,
working_directory: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Resolve a single git mount to volume mount entries. """Resolve a single git mount to volume mount entries.
@@ -108,28 +125,43 @@ async def _resolve_single_git_mount(
logger.warning("Invalid git mount skipped: missing remote_url or target_path") logger.warning("Invalid git mount skipped: missing remote_url or target_path")
return [] return []
# Resolve relative target paths against working directory
if target_path and not target_path.startswith("/"):
if not working_directory:
logger.warning(
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
"Set working_directory in the tool config or use an absolute path.",
target_path,
)
return []
target_path = os.path.join(working_directory, target_path)
logger.debug("Resolved relative target path to %s", target_path)
if not instance_dir: if not instance_dir:
logger.warning("Git mount skipped: no instance_dir provided for cloning") logger.warning("Git mount skipped: no instance_dir provided for cloning")
return [] return []
# Generate a unique directory name from the URL # Generate a unique directory name from the URL
import hashlib import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12] url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo" repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}") clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
# clone_repository always creates 'repo-clone' inside the given directory
repo_path = os.path.join(clone_parent, "repo-clone")
# Clone or pull the repository # Clone or pull the repository
repo_path = clone_dir if not os.path.exists(repo_path):
if not os.path.exists(clone_dir):
try: try:
os.makedirs(clone_parent, exist_ok=True)
repo_path = await asyncio.to_thread( repo_path = await asyncio.to_thread(
clone_repository, clone_repository,
remote_url, remote_url,
None, # No SSH key for now - can be added later None, # No SSH key for now - can be added later
os.path.dirname(clone_dir), clone_parent,
branch or "main", branch or "main",
) )
logger.info("Cloned git mount repository %s to %s", remote_url, repo_path) logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
except Exception as exc: except Exception as exc:
logger.warning("Clone failed for git mount %s: %s", remote_url, exc) logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
return [] return []
@@ -137,7 +169,7 @@ async def _resolve_single_git_mount(
# Repo exists - pull latest updates # Repo exists - pull latest updates
try: try:
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url) await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
logger.info("Pulled updates for git mount %s", remote_url) logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc: except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc) logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
@@ -145,11 +177,10 @@ async def _resolve_single_git_mount(
if branch and repo_path: if branch and repo_path:
success = await asyncio.to_thread(_checkout_branch, repo_path, branch) success = await asyncio.to_thread(_checkout_branch, repo_path, branch)
if success: if success:
logger.info("Checked out branch %s for %s", branch, remote_url) logger.debug("Checked out branch %s for %s", branch, remote_url)
else: else:
logger.warning( logger.warning(
"Branch %s not found in %s, using current branch", "Branch %s not found in %s, using current branch", branch, remote_url
branch, remote_url
) )
# Build source path and expand globs # Build source path and expand globs
@@ -162,7 +193,11 @@ async def _resolve_single_git_mount(
matched_paths = _expand_glob_source(source_full, repo_path) matched_paths = _expand_glob_source(source_full, repo_path)
if not matched_paths: if not matched_paths:
logger.warning("Git mount skipped: no files matched source path %s in %s", source_path, remote_url) logger.warning(
"Git mount skipped: no files matched source path %s in %s",
source_path,
remote_url,
)
return [] return []
volume_mounts = [] volume_mounts = []
@@ -179,12 +214,19 @@ async def _resolve_single_git_mount(
rel_path = os.path.relpath(matched_path, repo_path) rel_path = os.path.relpath(matched_path, repo_path)
final_target = os.path.join(target_path, rel_path) final_target = os.path.join(target_path, rel_path)
volume_mounts.append({ volume_mounts.append(
{
"source": matched_path, "source": matched_path,
"target": final_target, "target": final_target,
"type": "bind", "type": "bind",
}) }
logger.info("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url) )
logger.debug(
"Added git mount: %s -> %s (url: %s)",
matched_path,
final_target,
remote_url,
)
return volume_mounts return volume_mounts
@@ -218,7 +260,12 @@ def _checkout_branch(repo_path: str, branch: str) -> bool:
) )
if result.returncode != 0: if result.returncode != 0:
logger.warning("Failed to checkout branch %s in %s: %s", branch, repo_path, result.stderr.strip()) logger.warning(
"Failed to checkout branch %s in %s: %s",
branch,
repo_path,
result.stderr.strip(),
)
return False return False
return True return True
@@ -277,7 +324,11 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
if abs_path.startswith(os.path.abspath(repo_path)): if abs_path.startswith(os.path.abspath(repo_path)):
results.append(abs_path) results.append(abs_path)
if len(results) >= MAX_GLOB_MATCHES: if len(results) >= MAX_GLOB_MATCHES:
logger.warning("Glob pattern matched %d files, limited to %d", total_matched, MAX_GLOB_MATCHES) logger.warning(
"Glob pattern matched %d files, limited to %d",
total_matched,
MAX_GLOB_MATCHES,
)
break break
return results return results
@@ -292,11 +343,21 @@ class CreateInstanceRequest(BaseModel):
model_config = {"extra": "ignore"} model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate") tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance") display_name: str | None = Field(
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") default=None, description="Optional display name for the instance"
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") )
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning") clone_mode: str = Field(
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch") default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
class StartInstanceRequest(BaseModel): class StartInstanceRequest(BaseModel):
@@ -304,7 +365,9 @@ class StartInstanceRequest(BaseModel):
model_config = {"extra": "ignore"} model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none") config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
async def _validate_config_profile( async def _validate_config_profile(
@@ -370,6 +433,47 @@ async def _validate_config_profile(
return profile_uuid return profile_uuid
def _sanitize_compose_file(compose_path: str) -> None:
"""Remove invalid port mappings (target port 0) from compose file."""
import yaml
from pathlib import Path
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
modified = False
for service_name, service_config in compose_data["services"].items():
if "ports" in service_config:
valid_ports = []
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and ":" in port_mapping:
parts = port_mapping.split(":")
if len(parts) == 2:
host_port, container_port = parts
# Skip invalid mappings (target port 0 or empty)
if container_port == "0" or not container_port:
modified = True
continue
valid_ports.append(port_mapping)
if valid_ports:
service_config["ports"] = valid_ports
else:
del service_config["ports"]
modified = True
break # Only check first service
if modified:
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
def _modify_compose_file( def _modify_compose_file(
compose_path: str, compose_path: str,
port_override: int | None = None, port_override: int | None = None,
@@ -447,7 +551,7 @@ async def create_instance(
Returns: Returns:
Dictionary with instance details. Dictionary with instance details.
""" """
logger.info( logger.debug(
"Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s", "Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s",
project_id, project_id,
repo_id, repo_id,
@@ -481,17 +585,19 @@ async def create_instance(
if not repo.remote_url: if not repo.remote_url:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository does not have a remote URL for cloning" detail="repository does not have a remote URL for cloning",
) )
if not repo.ssh_key_id: if not repo.ssh_key_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository must have an SSH key assigned for clone mode" detail="repository must have an SSH key assigned for clone mode",
) )
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" instance_display = (
data.display_name or f"{tool_type.display_name} - {repo.name}"
)
# Create instance directory # Create instance directory
instance_dir = ensure_instance_directory(instance_name) instance_dir = ensure_instance_directory(instance_name)
@@ -507,7 +613,7 @@ async def create_instance(
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="repository SSH key not found" detail="repository SSH key not found",
) )
# Prepare SSH key for clone operation # Prepare SSH key for clone operation
@@ -529,7 +635,7 @@ async def create_instance(
cleanup_ssh_key_files(instance_dir) cleanup_ssh_key_files(instance_dir)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}" detail=f"Failed to clone repository: {exc}",
) )
else: else:
repo_path = repo.path repo_path = repo.path
@@ -538,15 +644,21 @@ async def create_instance(
if data.clone_mode == "clone" and repo_path: if data.clone_mode == "clone" and repo_path:
try: try:
repo_contents = os.listdir(repo_path) repo_contents = os.listdir(repo_path)
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"): if not repo_contents or (
len(repo_contents) == 1 and repo_contents[0] == ".git"
):
logger.error("Cloned repository at %s appears empty", repo_path) logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty") raise RuntimeError("Cloned repository is empty")
logger.info("Verified cloned repo at %s has %d items", repo_path, len(repo_contents)) logger.debug(
"Verified cloned repo at %s has %d items",
repo_path,
len(repo_contents),
)
except Exception as exc: except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc) logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cloned repository verification failed: {exc}" detail=f"Cloned repository verification failed: {exc}",
) )
# Create new local branch if requested # Create new local branch if requested
@@ -558,23 +670,28 @@ async def create_instance(
text=True, text=True,
) )
if result.returncode != 0: if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr) logger.error(
"Failed to create branch %s: %s", data.new_branch, result.stderr
)
raise RuntimeError(f"Failed to create branch: {result.stderr}") raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch) logger.debug(
"Created local branch %s in cloned repository", data.new_branch
)
except Exception as exc: except Exception as exc:
logger.exception("Failed to create local branch: %s", exc) logger.exception("Failed to create local branch: %s", exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}" detail=f"Failed to create local branch: {exc}",
) )
# Handle based on definition type # Handle based on definition type
if tool_type.definition_type == "dockerfile": if tool_type.definition_type == "dockerfile":
# Build image from Dockerfile # Build image from Dockerfile
image_tag = f"headquarter/{instance_name}:latest" image_tag = f"headquarter/{instance_name}:latest".lower()
if tool_type.dockerfile_template: if tool_type.dockerfile_template:
returncode, stdout, stderr = build_image( returncode, stdout, stderr = await asyncio.to_thread(
build_image,
instance_dir=instance_dir, instance_dir=instance_dir,
dockerfile=tool_type.dockerfile_template, dockerfile=tool_type.dockerfile_template,
tag=image_tag, tag=image_tag,
@@ -582,23 +699,40 @@ async def create_instance(
) )
if returncode != 0: if returncode != 0:
logger.error("Failed to build image for instance %s: %s", instance_name, stderr) logger.error(
"Failed to build image for instance %s: %s",
instance_name,
stderr,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}", detail=f"Failed to build Docker image: {stderr[:500]}",
) )
logger.info("Successfully built image %s for instance %s", image_tag, instance_name) logger.info(
"Successfully built image %s for instance %s",
image_tag,
instance_name,
)
# Generate compose for dockerfile-built image # Generate compose for dockerfile-built image
# Only include ports if tool requires one (skip for terminal-only tools)
ports_section = (
f""" ports:
- "{tool_port}:{tool_type.default_port}"
"""
if tool_type.default_port and tool_type.default_port > 0
else ""
)
compose_content = f"""version: "3.8" compose_content = f"""version: "3.8"
services: services:
app: app:
image: {image_tag} image: {image_tag}
container_name: {instance_name} container_name: {instance_name.lower()}
ports: stdin_open: true
- "{tool_port}:{tool_type.default_port}" tty: true
volumes: {ports_section} volumes:
- {repo_path}:/workspace - {repo_path}:/workspace
restart: unless-stopped restart: unless-stopped
""" """
@@ -615,11 +749,14 @@ services:
"USER_ID": str(user_id), "USER_ID": str(user_id),
"PROJECT_ID": str(project_id), "PROJECT_ID": str(project_id),
} }
compose_content = render_compose_template(tool_type.compose_template, variables) compose_content = render_compose_template(
tool_type.compose_template, variables
)
# Safety check: for clone mode, ensure repo is mounted in compose file # Safety check: for clone mode, ensure repo is mounted in compose file
if data.clone_mode == "clone" and repo_path: if data.clone_mode == "clone" and repo_path:
import yaml import yaml
compose_data = yaml.safe_load(compose_content) compose_data = yaml.safe_load(compose_content)
repo_mounted = False repo_mounted = False
if compose_data and "services" in compose_data: if compose_data and "services" in compose_data:
@@ -645,7 +782,9 @@ services:
svc["volumes"] = [] svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace") svc["volumes"].append(f"{repo_path}:/workspace")
break break
compose_content = yaml.dump(compose_data, default_flow_style=False) compose_content = yaml.dump(
compose_data, default_flow_style=False
)
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
@@ -661,7 +800,9 @@ services:
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
clone_mode=data.clone_mode, clone_mode=data.clone_mode,
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None), branch=data.new_branch
if data.new_branch
else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id, selected_config_profile_id=selected_profile_id,
) )
session.add(instance) session.add(instance)
@@ -676,7 +817,9 @@ services:
"status": instance.status, "status": instance.status,
"clone_mode": instance.clone_mode, "clone_mode": instance.clone_mode,
"branch": instance.branch, "branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
except Exception as exc: except Exception as exc:
@@ -729,7 +872,8 @@ async def list_instances(
instances_data = [] instances_data = []
for i in instances: for i in instances:
tool_type = await session.get(ToolType, i.tool_type_id) tool_type = await session.get(ToolType, i.tool_type_id)
instances_data.append({ instances_data.append(
{
"id": str(i.id), "id": str(i.id),
"name": i.name, "name": i.name,
"display_name": i.display_name, "display_name": i.display_name,
@@ -742,7 +886,8 @@ async def list_instances(
"clone_mode": i.clone_mode, "clone_mode": i.clone_mode,
"branch": i.branch, "branch": i.branch,
"created_at": i.created_at.isoformat(), "created_at": i.created_at.isoformat(),
}) }
)
return {"instances": instances_data} return {"instances": instances_data}
@@ -803,9 +948,15 @@ async def get_instance(
"port": instance.port, "port": instance.port,
"clone_mode": instance.clone_mode, "clone_mode": instance.clone_mode,
"branch": instance.branch, "branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, if instance.selected_config_profile_id
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, else None,
"last_started_at": instance.last_started_at.isoformat()
if instance.last_started_at
else None,
"last_stopped_at": instance.last_stopped_at.isoformat()
if instance.last_stopped_at
else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
@@ -871,16 +1022,20 @@ async def start_instance(
extra_env_vars = {} extra_env_vars = {}
extra_volumes = [] extra_volumes = []
config_query = select(ToolConfig).where( config_query = (
select(ToolConfig)
.where(
ToolConfig.user_id == user_id, ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id, ToolConfig.tool_type_id == instance.tool_type_id,
).where( )
.where(
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
) )
)
config_result = await session.execute(config_query) config_result = await session.execute(config_query)
configs = config_result.scalars().all() configs = config_result.scalars().all()
logger.info("Found %d tool configs for instance %s", len(configs), instance.id) logger.debug("Found %d tool configs for instance %s", len(configs), instance.id)
for config in configs: for config in configs:
if config.config_type == "env": if config.config_type == "env":
@@ -907,9 +1062,11 @@ async def start_instance(
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None: if instance.selected_config_profile_id is not None:
try: try:
resolved = await resolve_profile(session, instance.selected_config_profile_id) resolved = await resolve_profile(
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( session, instance.selected_config_profile_id
instance_dir, resolved )
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
) )
# Profile env vars override tool config env vars # Profile env vars override tool config env vars
env_vars.update(profile_env) env_vars.update(profile_env)
@@ -918,7 +1075,9 @@ async def start_instance(
# Profile mounts are added to extra volumes # Profile mounts are added to extra volumes
extra_volumes.extend(profile_mounts) extra_volumes.extend(profile_mounts)
# Git repository mounts are resolved and added # Git repository mounts are resolved and added
git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir) git_mount_volumes = await _resolve_git_mounts(
session, resolved, instance_dir, working_directory
)
extra_volumes.extend(git_mount_volumes) extra_volumes.extend(git_mount_volumes)
# Profile runtime hints override tool config values # Profile runtime hints override tool config values
if profile_hints.get("start_command"): if profile_hints.get("start_command"):
@@ -927,7 +1086,7 @@ async def start_instance(
working_directory = profile_hints["working_directory"] working_directory = profile_hints["working_directory"]
if profile_hints.get("port_override"): if profile_hints.get("port_override"):
port_override = profile_hints["port_override"] port_override = profile_hints["port_override"]
logger.info( logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
resolved.profile_name, resolved.profile_name,
instance.id, instance.id,
@@ -937,24 +1096,28 @@ async def start_instance(
len(git_mount_volumes), len(git_mount_volumes),
) )
except ConfigProfileCycleError as exc: except ConfigProfileCycleError as exc:
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc) logger.error(
"Cycle detected in config profile for instance %s: %s", instance.id, exc
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Config profile cycle detected: {exc}", detail=f"Config profile cycle detected: {exc}",
) )
else: else:
logger.info("No config profile selected for instance %s", instance.id) logger.debug("No config profile selected for instance %s", instance.id)
# Write env file and config files # Write env file and config files
env_file_path = None env_file_path = None
if env_vars: if env_vars:
env_file_path = write_env_file(instance_dir, env_vars) env_file_path = write_env_file(instance_dir, env_vars)
logger.info("Wrote env file for instance %s: %s", instance.id, env_file_path) logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path)
if config_files: if config_files:
write_config_files(instance_dir, config_files) write_config_files(instance_dir, config_files)
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id) logger.debug(
"Wrote %d config files for instance %s", len(config_files), instance.id
)
# Mount SSH key for clone-mode instances # Mount SSH key for clone-mode instances
if instance.clone_mode == "clone": if instance.clone_mode == "clone":
@@ -964,27 +1127,53 @@ async def start_instance(
if ssh_key: if ssh_key:
try: try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({ extra_volumes.append(
{
"source": ssh_dir, "source": ssh_dir,
"target": "/root/.ssh", "target": "/root/.ssh",
"type": "ro", "type": "ro",
}) }
logger.info("Mounted SSH key for clone-mode instance %s", instance.id) )
logger.debug(
"Mounted SSH key for clone-mode instance %s", instance.id
)
except Exception as exc: except Exception as exc:
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc) logger.error(
"Failed to prepare SSH key for instance %s: %s",
instance.id,
exc,
)
# Modify compose file if needed (port override, start command, working dir, volumes) # Modify compose file if needed (port override, start command, working dir, volumes)
if port_override or start_command or working_directory or extra_volumes: if port_override or start_command or working_directory or extra_volumes:
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) _modify_compose_file(
logger.info("Modified compose file for instance %s", instance.id) instance.compose_path,
port_override,
start_command,
working_directory,
extra_volumes,
)
logger.debug("Modified compose file for instance %s", instance.id)
# Sanitize compose file to remove invalid port mappings from old instances
_sanitize_compose_file(instance.compose_path)
# Execute docker compose up with env file # Execute docker compose up with env file
logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path) logger.debug(
"Running docker compose up for instance %s (compose_path=%s)",
instance.id,
instance.compose_path,
)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "up", env_file=env_file_path instance.compose_path, "up", env_file=env_file_path
) )
logger.info("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", logger.debug(
instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "") "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s",
instance.id,
returncode,
stdout[:200] if stdout else "",
stderr[:500] if stderr else "",
)
if returncode != 0: if returncode != 0:
instance.status = "error" instance.status = "error"
@@ -999,18 +1188,18 @@ async def start_instance(
container_id = get_container_id(instance.name) container_id = get_container_id(instance.name)
if container_id: if container_id:
instance.container_id = container_id instance.container_id = container_id
logger.info("Container ID for instance %s: %s", instance.id, container_id) logger.debug("Container ID for instance %s: %s", instance.id, container_id)
container_name = get_container_name(instance.name) container_name = get_container_name(instance.name)
if container_name: if container_name:
instance.container_name = container_name instance.container_name = container_name
logger.info("Container name for instance %s: %s", instance.id, container_name) logger.debug("Container name for instance %s: %s", instance.id, container_name)
# Connect container to backend network so API can reach it # Connect container to backend network so API can reach it
logger.info("Connecting container %s to backend network...", container_name) logger.debug("Connecting container %s to backend network...", container_name)
connected = connect_container_to_network(container_name, "backend") connected = connect_container_to_network(container_name, "backend")
if connected: if connected:
logger.info("Successfully connected %s to backend network", container_name) logger.debug("Successfully connected %s to backend network", container_name)
else: else:
logger.warning("Failed to connect %s to backend network", container_name) logger.warning("Failed to connect %s to backend network", container_name)
@@ -1019,9 +1208,11 @@ async def start_instance(
instance.status = "starting" instance.status = "starting"
instance.last_started_at = datetime.now() instance.last_started_at = datetime.now()
await session.commit() await session.commit()
logger.info("Instance %s: verifying container startup...", instance.id) logger.debug("Instance %s: verifying container startup...", instance.id)
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0) startup_result = wait_for_container_running(
instance.container_id, timeout=30, interval=2.0
)
if not startup_result["success"]: if not startup_result["success"]:
# Container failed to start # Container failed to start
@@ -1047,7 +1238,7 @@ async def start_instance(
"logs": logs, "logs": logs,
} }
logger.info( logger.debug(
"Instance %s container started successfully after %.1fs", "Instance %s container started successfully after %.1fs",
instance.id, instance.id,
startup_result["waited_seconds"], startup_result["waited_seconds"],
@@ -1075,9 +1266,12 @@ async def start_instance(
if probe_command: if probe_command:
instance.status = "probing" instance.status = "probing"
await session.commit() await session.commit()
logger.info( logger.debug(
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
instance.id, probe_command, probe_timeout, probe_interval instance.id,
probe_command,
probe_timeout,
probe_interval,
) )
success, probe_logs = await execute_probe( success, probe_logs = await execute_probe(
@@ -1128,15 +1322,24 @@ async def start_instance(
} }
instance_port = tool_type.default_port or 0 instance_port = tool_type.default_port or 0
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", logger.debug(
instance.id, tool_type.name, instance_port, tool_type.interface_type) "Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id,
tool_type.name,
instance_port,
tool_type.interface_type,
)
# Only create Cloudflare tunnel for web-enabled tools # Only create Cloudflare tunnel for web-enabled tools
if tool_type.interface_type == "web": if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", logger.debug(
instance.id, instance.container_name, instance_port) "Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id,
instance.container_name,
instance_port,
)
tunnel_info = start_cloudflared_tunnel( tunnel_info = start_cloudflared_tunnel(
container_name=instance.container_name or instance.name, container_name=instance.container_name or instance.name,
port=instance_port, port=instance_port,
@@ -1145,7 +1348,7 @@ async def start_instance(
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.info( logger.debug(
"Created temporary tunnel for instance %s: pid=%s, url=%s", "Created temporary tunnel for instance %s: pid=%s, url=%s",
instance.id, instance.id,
tunnel_info["pid"], tunnel_info["pid"],
@@ -1153,6 +1356,7 @@ async def start_instance(
) )
except Exception as exc: except Exception as exc:
import traceback import traceback
error_msg = str(exc) error_msg = str(exc)
error_trace = traceback.format_exc() error_trace = traceback.format_exc()
logger.error( logger.error(
@@ -1170,7 +1374,10 @@ async def start_instance(
} }
else: else:
# Terminal-only tool - no tunnel needed # Terminal-only tool - no tunnel needed
logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id) logger.info(
"Instance %s is terminal-only (no web interface), skipping tunnel creation",
instance.id,
)
instance.url = None instance.url = None
instance.public_url = None instance.public_url = None
await session.commit() await session.commit()
@@ -1215,9 +1422,15 @@ async def stop_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "stop") execute_compose_command(instance.compose_path, "stop")
@@ -1269,23 +1482,31 @@ async def restart_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.info("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped old tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop old tunnel for instance %s: %s", instance.id, exc
)
# Re-apply stored config profile on restart # Re-apply stored config profile on restart
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None: if instance.selected_config_profile_id is not None:
try: try:
resolved = await resolve_profile(session, instance.selected_config_profile_id) resolved = await resolve_profile(
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( session, instance.selected_config_profile_id
instance_dir, resolved )
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
) )
# Write env file with resolved profile env vars # Write env file with resolved profile env vars
if profile_env: if profile_env:
write_env_file(instance_dir, profile_env) write_env_file(instance_dir, profile_env)
logger.info( logger.debug(
"Re-applied config profile %s on restart for instance %s", "Re-applied config profile %s on restart for instance %s",
resolved.profile_name, resolved.profile_name,
instance.id, instance.id,
@@ -1308,8 +1529,10 @@ async def restart_instance(
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type or not tool_type.default_port: if not tool_type or not tool_type.default_port:
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", logger.error(
instance.tool_type_id) "Tool type %s has no default_port configured. Cannot create tunnel.",
instance.tool_type_id,
)
instance.status = "error" instance.status = "error"
await session.commit() await session.commit()
return { return {
@@ -1330,7 +1553,7 @@ async def restart_instance(
instance.tunnel_id = tunnel_info["pid"] instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
logger.info( logger.debug(
"Created new tunnel for instance %s: %s", "Created new tunnel for instance %s: %s",
instance.id, instance.id,
tunnel_info["url"], tunnel_info["url"],
@@ -1397,7 +1620,9 @@ async def delete_instance(
# Check dirty state for clone-mode instances # Check dirty state for clone-mode instances
if instance.clone_mode == "clone" and not force: if instance.clone_mode == "clone" and not force:
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None instance_dir = (
os.path.dirname(instance.compose_path) if instance.compose_path else None
)
if instance_dir: if instance_dir:
clone_path = os.path.join(instance_dir, "repo-clone") clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path): if os.path.exists(clone_path):
@@ -1416,9 +1641,15 @@ async def delete_instance(
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_cloudflared_tunnel(instance.tunnel_id) stop_cloudflared_tunnel(instance.tunnel_id)
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) logger.debug(
"Stopped tunnel for instance %s (pid=%s)",
instance.id,
instance.tunnel_id,
)
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) logger.warning(
"Failed to stop tunnel for instance %s: %s", instance.id, exc
)
# Stop and remove container # Stop and remove container
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
@@ -1429,6 +1660,7 @@ async def delete_instance(
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
import shutil import shutil
shutil.rmtree(instance_dir) shutil.rmtree(instance_dir)
await session.delete(instance) await session.delete(instance)
@@ -1525,11 +1757,17 @@ async def recreate_tunnel_endpoint(
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.", detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
) )
elif tunnel_health["tunnel_status"] == "healthy": elif tunnel_health["tunnel_status"] == "healthy":
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"} return {
"status": "healthy",
"url": instance.url,
"message": "Tunnel is already healthy",
}
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 instance_port = (
tool_type.default_port if tool_type and tool_type.default_port else 8080
)
try: try:
tunnel_info = recreate_tunnel( tunnel_info = recreate_tunnel(
@@ -1541,7 +1779,7 @@ async def recreate_tunnel_endpoint(
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.info( logger.debug(
"Recreated tunnel for instance %s: pid=%s, url=%s", "Recreated tunnel for instance %s: pid=%s, url=%s",
instance.id, instance.id,
tunnel_info["pid"], tunnel_info["pid"],
@@ -1610,7 +1848,9 @@ async def check_instance_tunnel_health(
if instance.status == "probing": if instance.status == "probing":
response["probe_status"] = "pending" response["probe_status"] = "pending"
elif instance.probe_result: elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" response["probe_status"] = (
"success" if instance.probe_result.get("success") else "failed"
)
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
# Check tunnel health if instance has a URL and is web-enabled # Check tunnel health if instance has a URL and is web-enabled
@@ -1769,10 +2009,9 @@ async def proxy_to_instance(
) )
from fastapi import APIRouter as FastAPIRouter
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get( @sessions_router.get(
"/me/sessions", "/me/sessions",
summary="Get user sessions", summary="Get user sessions",
@@ -1796,7 +2035,11 @@ async def get_user_sessions(
result = await session.execute( result = await session.execute(
select(ToolInstance) select(ToolInstance)
.where(ToolInstance.owner_id == user_id) .where(ToolInstance.owner_id == user_id)
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"])) .where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc()) .order_by(ToolInstance.created_at.desc())
) )
instances = result.scalars().all() instances = result.scalars().all()
@@ -1807,7 +2050,8 @@ async def get_user_sessions(
repo = await session.get(GitRepository, instance.repository_id) repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id) project = await session.get(Project, instance.project_id)
sessions.append({ sessions.append(
{
"id": str(instance.id), "id": str(instance.id),
"display_name": instance.display_name, "display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_name": tool_type.name if tool_type else "unknown",
@@ -1821,8 +2065,13 @@ async def get_user_sessions(
"url": instance.url, "url": instance.url,
"clone_mode": instance.clone_mode, "clone_mode": instance.clone_mode,
"branch": instance.branch, "branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id)
"created_at": instance.created_at.isoformat() if instance.created_at else None, if instance.selected_config_profile_id
}) else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions} return {"sessions": sessions}
-2
View File
@@ -1,7 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import ( from src.api.tool_types_validation import (
check_port_exposed, check_port_exposed,
sanitize_template_vars,
validate_compose_yaml, validate_compose_yaml,
validate_required_variables, validate_required_variables,
) )
+5 -6
View File
@@ -1,17 +1,16 @@
import logging import logging
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
logger = logging.getLogger(__name__)
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session 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 from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/users/me", tags=["user-config"]) router = APIRouter(prefix="/users/me", tags=["user-config"])
@@ -103,11 +102,11 @@ async def update_user_config(
# Merge updates # Merge updates
update_data = data.model_dump(exclude_unset=True) 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 # SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data} config.config = {**config.config, **update_data}
await session.commit() await session.commit()
await session.refresh(config) await session.refresh(config)
logger.info("Updated config: %s", config.config) logger.debug("Updated config: %s", config.config)
return UserConfigResponse.model_validate(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.auth.session import decode_session_cookie
from src.config import Settings from src.config import Settings
from src.database import SessionLocal from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User from src.models.user import User
-2
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
import os import os
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from src.api.auth import router as auth_router from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router from src.api.dashboard import router as dashboard_router
+3 -3
View File
@@ -42,7 +42,7 @@ def clone_repository(
str(clone_path), 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( result = subprocess.run(
cmd, cmd,
capture_output=True, capture_output=True,
@@ -55,7 +55,7 @@ def clone_repository(
logger.error("Git clone failed: %s", result.stderr) logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {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) return str(clone_path)
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
if clone_path.exists(): if clone_path.exists():
import shutil import shutil
shutil.rmtree(clone_path) shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path) logger.debug("Removed clone directory: %s", clone_path)
+59 -20
View File
@@ -1,7 +1,9 @@
"""Docker service for managing tool instances.""" """Docker service for managing tool instances."""
import os import os
import re
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from typing import Any 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: if base_path is None:
from src.config import Settings from src.config import Settings
base_path = Settings().instance_base_path base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True) instance_dir.mkdir(parents=True, exist_ok=True)
@@ -136,6 +139,8 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None: def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service. """Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args: Args:
instance_name: The service name in compose 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 Container ID or None if not found
""" """
result = subprocess.run( result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"], ["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
capture_output=True, capture_output=True,
text=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: def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service. """Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args: Args:
instance_name: The service name in compose 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 Container name or None if not found
""" """
result = subprocess.run( 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, capture_output=True,
text=True, text=True,
) )
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
return 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. """Connect a Docker container to an existing network.
Args: Args:
@@ -203,7 +220,9 @@ def get_container_status(container_id: str) -> dict[str, Any]:
""" """
result = subprocess.run( result = subprocess.run(
[ [
"docker", "inspect", "-f", "docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", "{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id, container_id,
], ],
@@ -238,7 +257,6 @@ def wait_for_container_running(
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None), Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float) and 'waited_seconds' (float)
""" """
import time
start_time = time.time() start_time = time.time()
@@ -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}") raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel( def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30 container_name: str, port: int, timeout: int = 30
) -> dict[str, str]: ) -> dict[str, str]:
@@ -344,8 +357,6 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID) Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
""" """
import subprocess import subprocess
import time
import re
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
logger.info("Checking connectivity to %s:%d...", container_name, port) logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10): for attempt in range(10):
check = subprocess.run( 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, capture_output=True,
text=True, text=True,
timeout=5, 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: if check.returncode == 0:
break break
time.sleep(1) time.sleep(1)
else: 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 # Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port) 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: while time.time() - start_time < timeout:
# Read available output # Read available output
import select import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0) readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable: if readable:
line = proc.stdout.readline() line = proc.stdout.readline()
@@ -410,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args: Args:
pid: Process ID of the cloudflared tunnel pid: Process ID of the cloudflared tunnel
""" """
import os
import signal import signal
try: try:
@@ -455,8 +477,17 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
try: try:
result = subprocess.run( 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, capture_output=True,
text=True, text=True,
timeout=timeout + 5, timeout=timeout + 5,
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
error_str = str(e).lower() error_str = str(e).lower()
# Classify connection errors # 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 { return {
"tunnel_status": "unreachable", "tunnel_status": "unreachable",
"status_code": None, "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: Returns:
Tuple of (returncode, stdout, stderr) Tuple of (returncode, stdout, stderr)
""" """
import os
from pathlib import Path from pathlib import Path
# Write Dockerfile # Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile" dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(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 # Write build context files
if build_context: 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.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content) 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 # Build image
logger.info("Building Docker image with tag: %s", tag) logger.debug("Building Docker image with tag: %s", tag)
cmd = [ cmd = [
"docker", "build", "docker", "build",
"-t", tag, "-t", tag,
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
text=True, text=True,
timeout=300, # 5 minute timeout for builds 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: if result.returncode != 0:
logger.error("Docker build failed: %s", result.stderr[:1000]) logger.error("Docker build failed: %s", result.stderr[:1000])
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr
+4 -5
View File
@@ -3,7 +3,6 @@
import asyncio import asyncio
import logging import logging
import uuid import uuid
from typing import Any
from fastapi import WebSocket from fastapi import WebSocket
@@ -72,11 +71,11 @@ class TerminalManager:
# Check if session is still alive # Check if session is still alive
if session.is_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 return session
else: else:
# Session died, clean it up # 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() await session.close()
del self._sessions[instance_id_str] del self._sessions[instance_id_str]
@@ -97,7 +96,7 @@ class TerminalManager:
"""Attach a WebSocket to an existing session.""" """Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones # Handle concurrent connections - close existing ones
if session.has_websockets(): 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): for ws in list(session._websockets):
try: try:
await ws.close(code=4000, reason="New connection established") await ws.close(code=4000, reason="New connection established")
@@ -135,7 +134,7 @@ class TerminalManager:
# Close existing session if any # Close existing session if any
if instance_id_str in self._sessions: 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) old_session = self._sessions.pop(instance_id_str)
await old_session.close() await old_session.close()
+4 -4
View File
@@ -60,12 +60,12 @@ class TerminalSession:
# Set the terminal size initially # Set the terminal size initially
self._set_terminal_size(self._cols, self._rows) 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 # Build the shell command
if startup_command: if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il' 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: else:
shell_cmd = "bash -il" shell_cmd = "bash -il"
@@ -102,7 +102,7 @@ class TerminalSession:
size = struct.pack('HHHH', rows, cols, 0, 0) size = struct.pack('HHHH', rows, cols, 0, 0)
try: try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) 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: except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}") logger.error(f"Failed to resize PTY: {e}")
@@ -159,7 +159,7 @@ class TerminalSession:
self._cols = cols self._cols = cols
self._rows = rows 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) self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container, # Docker exec -it creates its own PTY inside the container,
-1
View File
@@ -2,7 +2,6 @@
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str: 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
import pytest_asyncio import pytest_asyncio
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules # Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing" os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" 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.models.base import Base
from src.main import app from src.main import app
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
@@ -402,8 +402,8 @@ class TestConfigProfilesAPI:
) )
assert response.status_code == 422 assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None: def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount target paths are rejected.""" """Test that git mount target paths with traversal are rejected."""
_project_id, repo_id = test_project_and_repo _project_id, repo_id = test_project_and_repo
response = authenticated_client.post( response = authenticated_client.post(
@@ -416,7 +416,7 @@ class TestConfigProfilesAPI:
{ {
"remote_url": "https://github.com/user/repo.git", "remote_url": "https://github.com/user/repo.git",
"source_path": ".", "source_path": ".",
"target_path": "relative/path", "target_path": "../../../etc/passwd",
} }
], ],
}, },
-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 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.asyncio
@pytest.mark.integration @pytest.mark.integration
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
import asyncio import asyncio
import pytest import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -1,4 +1,3 @@
import uuid
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -1,4 +1,3 @@
import uuid
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
}, },
) )
assert response.status_code == 422 assert response.status_code == 422
data = response.json() _ = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command.""" """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 ( from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
ConfigProfileNotFoundError, ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle, check_include_cycle,
resolve_profile, resolve_profile,
_merge_env_vars, _merge_env_vars,
@@ -63,7 +62,6 @@ class TestMergeFunctions:
def test_merge_mounts_basic(self) -> None: def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging.""" """Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts( result = _merge_mounts(
{}, {},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}], [{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
@@ -1,7 +1,6 @@
"""Unit tests for git mount resolution in tool instances.""" """Unit tests for git mount resolution in tool instances."""
import os import os
import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
_expand_glob_source, _expand_glob_source,
_resolve_single_git_mount, _resolve_single_git_mount,
) )
from src.services.config_profile_resolver import ResolvedProfile
class TestExpandGlobSource: class TestExpandGlobSource:
@@ -1,6 +1,5 @@
"""Tests for git URL parsing utilities.""" """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 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.""" """Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
@@ -3,10 +3,7 @@
import os import os
import subprocess import subprocess
import tempfile import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest 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"; 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; const RETRY_DELAY_MS = 1000;
// Track retry count per request // Track retry count per request
const retryCount = new WeakMap<any, number>(); const retryCount = new WeakMap<AxiosRequestConfig, number>();
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
+7 -4
View File
@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ToolInstance { export interface ToolInstance {
@@ -80,9 +81,10 @@ export async function startInstance(
{ config_profile_id: configProfileId } { config_profile_id: configProfileId }
); );
return response.data; return response.data;
} catch (error: any) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // 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)); await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
} }
@@ -114,9 +116,10 @@ export async function restartInstance(
{ config_profile_id: configProfileId } { config_profile_id: configProfileId }
); );
return response.data; return response.data;
} catch (error: any) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // 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)); await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
} }
+3 -5
View File
@@ -16,9 +16,8 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
branch: "", branch: "",
}); });
const handleAdd = () => { const handleAdd = (mount: GitMount) => {
if (!newMount.remote_url || !newMount.target_path) return; onChange([...mounts, mount]);
onChange([...mounts, { ...newMount }]);
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" }); setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}; };
@@ -36,7 +35,6 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const validatePath = (path: string, isTarget: boolean): string | null => { const validatePath = (path: string, isTarget: boolean): string | null => {
if (!path) return isTarget ? "Target path is required" : null; if (!path) return isTarget ? "Target path is required" : null;
if (path.includes("..")) return "Path cannot contain .."; 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"; if (!isTarget && path.startsWith("/")) return "Source path must be relative";
return null; return null;
}; };
@@ -200,7 +198,7 @@ const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNe
placeholder="e.g., /app/config" placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""} 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>} {errors.target_path && <span className="error-text">{errors.target_path}</span>}
</div> </div>
-1
View File
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
const sizeValue = sizeMap[size]; const sizeValue = sizeMap[size];
if (!IconComponent) { if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null; return null;
} }
@@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "./icon";
interface FormField { interface FormField {
name: string; name: string;
@@ -22,8 +22,6 @@ interface MobileListViewProps {
export const MobileListView: React.FC<MobileListViewProps> = ({ export const MobileListView: React.FC<MobileListViewProps> = ({
items, items,
onItemClick, onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found", emptyMessage = "No items found",
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
onSearch, onSearch,
+58 -42
View File
@@ -4,7 +4,10 @@ import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys"; import {
applyModifierToChar,
type ModifierKey,
} from "../hooks/use-special-keys";
interface TerminalProps { interface TerminalProps {
instanceId: string; instanceId: string;
@@ -14,9 +17,14 @@ interface TerminalProps {
onModifierChange?: (modifier: ModifierKey | null) => void; onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: ( onTerminalReady?: (
sendData: (data: string) => void, sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting",
focusInput: () => void, focusInput: () => void,
changeFontSize: (delta: number) => void changeFontSize: (delta: number) => void,
) => void; ) => void;
} }
@@ -62,6 +70,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const lastPingRef = useRef<number>(0); const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null); const heartbeatCheckRef = useRef<number | null>(null);
const isUnmountingRef = useRef(false); const isUnmountingRef = useRef(false);
const permanentErrorRef = useRef<string | null>(null);
const calculateFontSize = useCallback(() => { const calculateFontSize = useCallback(() => {
return fontSize; return fontSize;
@@ -73,12 +82,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
console.log(`[Terminal WS] Connecting to ${wsUrl} (attempt ${reconnectAttemptsRef.current + 1}/${RECONNECT_ATTEMPTS + 1})`); // WebSocket connection established
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
console.log(`[Terminal WS] Connected successfully`);
setStatus("connected"); setStatus("connected");
setError(null); setError(null);
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
@@ -99,10 +107,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
} }
heartbeatCheckRef.current = window.setInterval(() => { heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current; const elapsed = Date.now() - lastPingRef.current;
console.log(`[Terminal WS] Heartbeat check: lastPing=${elapsed}ms ago`);
if (elapsed > 60000) { if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead // No ping for 60 seconds, connection may be dead
console.warn("[Terminal WS] Heartbeat timeout (>60s), closing connection");
ws.close(4000, "Heartbeat timeout"); ws.close(4000, "Heartbeat timeout");
} }
}, 30000); }, 30000);
@@ -132,7 +138,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const { cols, rows } = termRef.current; const { cols, rows } = termRef.current;
const currentWs = wsRef.current; const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) { if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows })); currentWs.send(
JSON.stringify({ type: "resize", cols, rows }),
);
} }
} }
}); });
@@ -143,7 +151,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
} else if (msg.type === "ping") { } else if (msg.type === "ping") {
// Respond with pong and update last ping time // Respond with pong and update last ping time
lastPingRef.current = Date.now(); lastPingRef.current = Date.now();
console.log(`[Terminal WS] Received ping, sending pong`);
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" })); ws.send(JSON.stringify({ type: "pong" }));
} }
@@ -155,46 +162,52 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
console.log(`[Terminal WS] Connection closed: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, attempts=${reconnectAttemptsRef.current}`);
setStatus("disconnected");
// Clean up heartbeat check // Clean up heartbeat check
if (heartbeatCheckRef.current) { if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current); window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null; heartbeatCheckRef.current = null;
} }
if (event.code !== 1000 && event.code !== 4000) { // Permanent errors: do not retry
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
const reason = event.reason || `Instance error (code: ${event.code})`;
setStatus("error");
setError(reason);
permanentErrorRef.current = reason;
return;
}
if (event.code === 1000) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`); setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++; reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1); const delay =
console.log(`[Terminal WS] Will retry in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})`); RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => { setTimeout(() => {
if (isUnmountingRef.current) { if (isUnmountingRef.current) {
console.log(`[Terminal WS] Component unmounting, skipping reconnect`);
return; return;
} }
if (document.visibilityState !== "hidden") { if (document.visibilityState !== "hidden") {
connectWebSocket(); connectWebSocket();
} else {
console.log(`[Terminal WS] Tab hidden, skipping reconnect`);
} }
}, delay); }, delay);
} else {
console.log(`[Terminal WS] Max reconnection attempts (${RECONNECT_ATTEMPTS}) reached`);
}
} else if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
console.log(`[Terminal WS] Server closed old connection (concurrent/heartbeat)`);
} }
}; };
ws.onerror = (error) => { ws.onerror = () => {
console.error(`[Terminal WS] Error event fired`, error);
setStatus("error"); setStatus("error");
setError("WebSocket error"); setError("WebSocket error");
}; };
@@ -250,8 +263,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Define fitTerminal before connectWebSocket so it's available in onmessage // Define fitTerminal before connectWebSocket so it's available in onmessage
const fitTerminal = () => { const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return; if (!fitAddonRef.current || !termRef.current) return;
const oldCols = termRef.current.cols;
const oldRows = termRef.current.rows;
try { try {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
} catch { } catch {
@@ -259,7 +270,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
return; return;
} }
const { cols, rows } = termRef.current; const { cols, rows } = termRef.current;
console.log(`[Terminal] fit() result: ${cols}x${rows} (was ${oldCols}x${oldRows})`);
// Force refresh if dimensions are valid // Force refresh if dimensions are valid
if (cols > 0 && rows > 0) { if (cols > 0 && rows > 0) {
try { try {
@@ -285,14 +295,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
fitAttempts++; fitAttempts++;
// Ensure container has dimensions before fitting // Ensure container has dimensions before fitting
if (container.clientWidth > 0 && container.clientHeight > 0) { if (container.clientWidth > 0 && container.clientHeight > 0) {
console.log(`[Terminal] Container ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
fitTerminal(); fitTerminal();
} else if (fitAttempts < 50) { } else if (fitAttempts < 50) {
// Container not ready yet, try again (max 50 attempts ~ 1s) // Container not ready yet, try again (max 50 attempts ~ 1s)
console.log(`[Terminal] Container not ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
requestAnimationFrame(doInitialFit); requestAnimationFrame(doInitialFit);
} else {
console.warn(`[Terminal] Container never got dimensions after ${fitAttempts} attempts`);
} }
}; };
requestAnimationFrame(doInitialFit); requestAnimationFrame(doInitialFit);
@@ -379,9 +385,14 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Visibility API for reconnection // Visibility API for reconnection
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
console.log(`[Terminal WS] Visibility changed to: ${document.visibilityState}, wsState=${ws?.readyState}`); if (
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { document.visibilityState === "visible" &&
console.log(`[Terminal WS] Tab visible, resetting reconnect attempts and reconnecting`); ws &&
ws.readyState !== WebSocket.OPEN
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
connectWebSocket(); connectWebSocket();
} }
@@ -405,7 +416,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
} }
term.dispose(); term.dispose();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, connectWebSocket]); }, [instanceId, connectWebSocket]);
// Update parent about status changes // Update parent about status changes
@@ -427,7 +437,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
}, [status, onTerminalReady]); }, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => { const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta)); const newSize = Math.max(
MIN_FONT_SIZE,
Math.min(MAX_FONT_SIZE, fontSize + delta),
);
setFontSize(newSize); setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current && fitAddonRef.current) { if (termRef.current && fitAddonRef.current) {
@@ -443,7 +456,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
type: "resize", type: "resize",
cols, cols,
rows, rows,
}) }),
); );
} }
} catch { } catch {
@@ -564,7 +577,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
{showResetConfirm && ( {showResetConfirm && (
<div className="terminal-reset-confirm"> <div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content"> <div className="terminal-reset-confirm-content">
<p>Reset terminal? This will kill the current shell session and start fresh.</p> <p>
Reset terminal? This will kill the current shell session and start
fresh.
</p>
<div className="terminal-reset-confirm-buttons"> <div className="terminal-reset-confirm-buttons">
<button <button
className="terminal-reset-confirm-button cancel" className="terminal-reset-confirm-button cancel"
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; 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 { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors"; import { extractErrorMessage } from "../utils/errors";
-1
View File
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings"; import { updateUserConfig } from "../api/settings";
import type { Project } from "../types"; import type { Project } from "../types";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form"; import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions"; 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 { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories"; import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; 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 { Icon } from "../components/icon";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
+1 -3
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import type { Project } from "../types"; import type { Project } from "../types";
@@ -11,7 +10,7 @@ import {
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { getUserConfig, updateUserConfig } from "../api/settings"; 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 { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card"; import { SessionCard } from "../components/session-card";
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => { export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading"); const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null); 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 { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings"; 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 { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data"; 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 = () => { export const SSHKeysPage = () => {
const navigate = useNavigate(); 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 [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({}); const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
@@ -16,7 +16,7 @@ The system SHALL allow config profiles to include git repository mounts that bin
- **THEN** the system validates that: - **THEN** the system validates that:
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://) - `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
- `source_path` is a relative path (no leading `/`) - `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 (`..`) - `target_path` does not contain path traversal sequences (`..`)
- No database lookup or repository existence check is performed (validation is deferred to clone time) - No database lookup or repository existence check is performed (validation is deferred to clone time)
+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"]