Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a |
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -58,18 +57,16 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class GitMountItem(BaseModel):
|
class GitMountItem(BaseModel):
|
||||||
repo_id: str = Field(description="UUID of the git repository")
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||||
target_path: str = Field(description="Absolute path inside container")
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
|
||||||
@field_validator("repo_id")
|
@field_validator("remote_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_repo_id(cls, v: str) -> str:
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
try:
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
uuid.UUID(v)
|
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@@ -84,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
|
||||||
@@ -271,53 +266,23 @@ async def _validate_git_mounts(
|
|||||||
git_mounts: list[dict],
|
git_mounts: list[dict],
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate that all referenced git repositories exist and are accessible.
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
Repositories must:
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
1. Exist
|
Actual clone validation happens at instance startup time.
|
||||||
2. Belong to the user (external repos with no project are allowed)
|
|
||||||
3. If project_id is specified, repos can be either:
|
|
||||||
- External repos (project_id is null) belonging to the user
|
|
||||||
- Project repos belonging to that project
|
|
||||||
"""
|
"""
|
||||||
for mount in git_mounts:
|
for mount in git_mounts:
|
||||||
repo_id = mount.get("repo_id")
|
remote_url = mount.get("remote_url")
|
||||||
if not repo_id:
|
if not remote_url:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Git mount missing repo_id",
|
detail="Git mount missing remote_url",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
repo_uuid = uuid.UUID(repo_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Invalid repo_id UUID: {repo_id}",
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_uuid)
|
|
||||||
if repo is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Git repository not found: {repo_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if repo.owner_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Not authorized to access repository: {repo_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# External repos (no project) are always allowed for git mounts
|
|
||||||
if repo.project_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Project repos are allowed if they belong to the profile's project
|
|
||||||
if project_id is not None and repo.project_id != project_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Repository {repo_id} does not belong to project {project_id}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -454,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -555,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -575,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
|
||||||
|
|
||||||
|
|
||||||
@@ -660,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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -222,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID | None
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
is_mirror: bool
|
is_mirror: bool
|
||||||
remote_url: str | None
|
remote_url: str | None
|
||||||
@@ -232,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List all user repositories",
|
||||||
|
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
||||||
|
)
|
||||||
|
async def list_user_repositories(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories owned by the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all repositories owned by the user.
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.owner_id == user_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories/parse-url",
|
||||||
|
response_model=URLParseResponse,
|
||||||
|
summary="Parse a git URL",
|
||||||
|
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||||
|
)
|
||||||
|
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||||
|
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Request containing the URL to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||||
|
"""
|
||||||
|
result = parse_git_url(data.url)
|
||||||
|
return URLParseResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/repositories",
|
||||||
|
response_model=GitRepositoryResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create an external repository",
|
||||||
|
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||||
|
)
|
||||||
|
async def create_external_repository(
|
||||||
|
data: GitRepositoryCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Create a new external git repository.
|
||||||
|
|
||||||
|
External repositories are not tied to any project and can be used
|
||||||
|
across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Repository creation data including name and optional remote URL.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created external repository.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
|
# Check for duplicate name (external repos only)
|
||||||
|
existing = await session.execute(
|
||||||
|
select(GitRepository).where(
|
||||||
|
GitRepository.project_id.is_(None),
|
||||||
|
GitRepository.owner_id == user_id,
|
||||||
|
GitRepository.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||||
|
|
||||||
|
# Validate and potentially correct the URL
|
||||||
|
remote_url = data.remote_url
|
||||||
|
if remote_url and not data.force_original_url:
|
||||||
|
parse_result = parse_git_url(remote_url)
|
||||||
|
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||||
|
"suggested_url": parse_result["base_url"],
|
||||||
|
"original_url": remote_url,
|
||||||
|
"error_code": "URL_NEEDS_PARSING",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if parse_result["base_url"]:
|
||||||
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
# Validate SSH key if provided
|
||||||
|
ssh_key_id = None
|
||||||
|
ssh_key = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
try:
|
||||||
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
|
if ssh_key is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
if ssh_key.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
|
|
||||||
|
# Create external repo with no project
|
||||||
|
repo = GitRepository(
|
||||||
|
name=data.name,
|
||||||
|
path="", # Will be set after clone
|
||||||
|
project_id=None,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url=remote_url,
|
||||||
|
ssh_key_id=ssh_key_id,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Set path and optionally clone
|
||||||
|
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||||
|
repo.path = repo_path
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
try:
|
||||||
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||||
|
repo.is_mirror = False
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||||
|
else:
|
||||||
|
# Initialize empty repo
|
||||||
|
os.makedirs(repo_path, exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||||
|
repo.is_mirror = False
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -262,31 +410,6 @@ async def list_repositories(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/repositories",
|
|
||||||
response_model=list[GitRepositoryResponse],
|
|
||||||
summary="List all user repositories",
|
|
||||||
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
|
||||||
)
|
|
||||||
async def list_user_repositories(
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[GitRepository]:
|
|
||||||
"""List all repositories owned by the user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of all repositories owned by the user.
|
|
||||||
"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(GitRepository).where(GitRepository.owner_id == user_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{project_id}/repositories/{repo_id}",
|
"/{project_id}/repositories/{repo_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
@@ -326,25 +449,6 @@ async def delete_repository(
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/repositories/parse-url",
|
|
||||||
response_model=URLParseResponse,
|
|
||||||
summary="Parse a git URL",
|
|
||||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
|
||||||
)
|
|
||||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|
||||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Request containing the URL to parse.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
|
||||||
"""
|
|
||||||
result = parse_git_url(data.url)
|
|
||||||
return URLParseResponse(**result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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,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"])
|
||||||
|
|
||||||
|
|||||||
+483
-205
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
) # {"rel/path": "content", ...}
|
) # {"rel/path": "content", ...}
|
||||||
git_mounts: Mapped[list] = mapped_column(
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
JSON, default=list, nullable=False
|
JSON, default=list, nullable=False
|
||||||
) # [{"repo_id": "uuid", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -176,13 +176,13 @@ def _merge_git_mounts(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Merge git mounts from included profiles.
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
Later mounts override earlier ones with the same repo_id + target_path combo.
|
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||||
"""
|
"""
|
||||||
result = list(base)
|
result = list(base)
|
||||||
# Build lookup by (repo_id, target_path)
|
# Build lookup by (remote_url, target_path)
|
||||||
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
|
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||||
for mount in overlay:
|
for mount in overlay:
|
||||||
key = (mount["repo_id"], mount["target_path"])
|
key = (mount["remote_url"], mount["target_path"])
|
||||||
if key in seen:
|
if key in seen:
|
||||||
result[seen[key]] = dict(mount)
|
result[seen[key]] = dict(mount)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
@@ -369,7 +369,7 @@ class TestConfigProfilesAPI:
|
|||||||
json={
|
json={
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "config",
|
"source_path": "config",
|
||||||
"target_path": "/config",
|
"target_path": "/config",
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "/absolute/path",
|
"source_path": "/absolute/path",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
@@ -414,9 +414,9 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "relative/path",
|
"target_path": "../../../etc/passwd",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -436,7 +436,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -450,4 +450,4 @@ class TestConfigProfilesAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data["git_mounts"]) == 1
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["git_mounts"][0]["repo_id"] == repo_id
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
|
|||||||
@@ -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"}}],
|
||||||
@@ -102,18 +100,18 @@ class TestMergeFunctions:
|
|||||||
"""Test basic git mount merging."""
|
"""Test basic git mount merging."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[],
|
[],
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["repo_id"] == "repo1"
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result[0]["target_path"] == "/app"
|
assert result[0]["target_path"] == "/app"
|
||||||
|
|
||||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||||
"""Test that git mounts with same repo+target override."""
|
"""Test that git mounts with same repo+target override."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||||
[{"repo_id": "repo1", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -123,8 +121,8 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_different_targets(self) -> None:
|
def test_merge_git_mounts_different_targets(self) -> None:
|
||||||
"""Test that git mounts with different targets are preserved."""
|
"""Test that git mounts with different targets are preserved."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
[{"repo_id": "repo2", "source_path": ".", "target_path": "/config"}],
|
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -296,7 +294,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
@@ -304,7 +302,7 @@ class TestResolveProfile:
|
|||||||
|
|
||||||
result = await resolve_profile(db_session, profile.id)
|
result = await resolve_profile(db_session, profile.id)
|
||||||
assert len(result.git_mounts) == 1
|
assert len(result.git_mounts) == 1
|
||||||
assert result.git_mounts[0]["repo_id"] == "repo1"
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result.git_mounts[0]["target_path"] == "/app"
|
assert result.git_mounts[0]["target_path"] == "/app"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -320,7 +318,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(base)
|
db_session.add(base)
|
||||||
@@ -333,7 +331,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo2", "source_path": "config", "target_path": "/config"},
|
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(child)
|
db_session.add(child)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -91,23 +89,22 @@ class TestCheckoutBranch:
|
|||||||
assert result == "feature"
|
assert result == "feature"
|
||||||
|
|
||||||
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||||
"""Test checking out a non-existent branch raises error."""
|
"""Test checking out a non-existent branch returns False."""
|
||||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
(tmp_path / "file.txt").write_text("content")
|
(tmp_path / "file.txt").write_text("content")
|
||||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="Failed to checkout branch"):
|
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||||
_checkout_branch(str(tmp_path), "nonexistent")
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestResolveSingleGitMount:
|
class TestResolveSingleGitMount:
|
||||||
"""Unit tests for resolving a single git mount."""
|
"""Unit tests for resolving a single git mount."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_missing_repo(self, db_session) -> None:
|
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||||
"""Test that missing repo returns empty list."""
|
"""Test that missing remote_url returns empty list."""
|
||||||
git_mount = {
|
git_mount = {
|
||||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -119,21 +116,9 @@ class TestResolveSingleGitMount:
|
|||||||
async def test_resolve_missing_target_path(self, db_session) -> None:
|
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||||
"""Test that missing target path returns empty list."""
|
"""Test that missing target path returns empty list."""
|
||||||
git_mount = {
|
git_mount = {
|
||||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_invalid_repo_id(self, db_session) -> None:
|
|
||||||
"""Test that invalid repo_id returns empty list."""
|
|
||||||
git_mount = {
|
|
||||||
"repo_id": "not-a-uuid",
|
|
||||||
"source_path": ".",
|
|
||||||
"target_path": "/app",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
|
||||||
assert result == []
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for git URL parsing utilities."""
|
"""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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ConfigProfileMount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMount {
|
export interface GitMount {
|
||||||
repo_id: string;
|
remote_url: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
target_path: string;
|
target_path: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
|||||||
@@ -31,17 +31,24 @@ export interface URLParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
if (projectId) {
|
||||||
|
const response = await apiClient.get<GitRepository[]>(
|
||||||
|
`/projects/${projectId}/repositories`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
// List all user repositories (including external)
|
||||||
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get<GitRepository[]>("/projects/repositories");
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +60,13 @@ export async function createRepository(
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExternalRepository(
|
||||||
|
data: GitRepositoryCreate
|
||||||
|
): Promise<GitRepository> {
|
||||||
|
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { GitMount } from "../api/config_profiles";
|
import type { GitMount } from "../api/config_profiles";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
repositories: GitRepository[];
|
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [newMount, setNewMount] = useState<GitMount>({
|
const [newMount, setNewMount] = useState<GitMount>({
|
||||||
repo_id: "",
|
remote_url: "",
|
||||||
source_path: ".",
|
source_path: ".",
|
||||||
target_path: "",
|
target_path: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = (mount: GitMount) => {
|
||||||
if (!newMount.repo_id || !newMount.target_path) return;
|
onChange([...mounts, mount]);
|
||||||
onChange([...mounts, { ...newMount }]);
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index: number, updated: GitMount) => {
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
@@ -38,11 +35,18 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateUrl = (url: string): string | null => {
|
||||||
|
if (!url) return "Git URL is required";
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
||||||
|
return "Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-editor">
|
<div className="git-mount-editor">
|
||||||
<h4 className="section-subtitle">Git Mounts</h4>
|
<h4 className="section-subtitle">Git Mounts</h4>
|
||||||
@@ -54,17 +58,15 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
{editingIndex === index ? (
|
{editingIndex === index ? (
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={mount}
|
mount={mount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
|
validateUrl={validateUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="git-mount-display">
|
<div className="git-mount-display">
|
||||||
<div className="git-mount-info">
|
<div className="git-mount-info">
|
||||||
<span className="git-mount-repo">
|
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||||
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
|
|
||||||
</span>
|
|
||||||
<span className="git-mount-paths">
|
<span className="git-mount-paths">
|
||||||
{mount.source_path || "."} → {mount.target_path}
|
{mount.source_path || "."} → {mount.target_path}
|
||||||
</span>
|
</span>
|
||||||
@@ -101,10 +103,10 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
<h5>Add Git Mount</h5>
|
<h5>Add Git Mount</h5>
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={newMount}
|
mount={newMount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
|
validateUrl={validateUrl}
|
||||||
isNew
|
isNew
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,14 +116,14 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
|||||||
|
|
||||||
interface GitMountFormProps {
|
interface GitMountFormProps {
|
||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
repositories: GitRepository[];
|
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||||
|
validateUrl: (url: string) => string | null;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -139,9 +141,8 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!form.repo_id) {
|
const urlError = validateUrl(form.remote_url);
|
||||||
newErrors.repo_id = "Repository is required";
|
if (urlError) newErrors.remote_url = urlError;
|
||||||
}
|
|
||||||
|
|
||||||
const sourceError = validatePath(form.source_path || ".", false);
|
const sourceError = validatePath(form.source_path || ".", false);
|
||||||
if (sourceError) newErrors.source_path = sourceError;
|
if (sourceError) newErrors.source_path = sourceError;
|
||||||
@@ -156,27 +157,23 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
|||||||
|
|
||||||
onSave(form);
|
onSave(form);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-form">
|
<div className="git-mount-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>Repository</label>
|
<label>Git URL</label>
|
||||||
<select
|
<input
|
||||||
value={form.repo_id}
|
type="text"
|
||||||
onChange={(e) => handleChange("repo_id", e.target.value)}
|
value={form.remote_url}
|
||||||
className={errors.repo_id ? "error" : ""}
|
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||||
>
|
placeholder="https://github.com/user/repo.git"
|
||||||
<option value="">Select a repository...</option>
|
className={errors.remote_url ? "error" : ""}
|
||||||
{repositories.map((repo) => (
|
/>
|
||||||
<option key={repo.id} value={repo.id}>
|
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||||
{repo.name}
|
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -201,7 +198,7 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listAllUserRepositories, type GitRepository } from "../api/git_repositories";
|
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
@@ -34,7 +33,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -72,14 +70,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
|
|
||||||
// Load all user repositories (including external ones)
|
|
||||||
try {
|
|
||||||
const allRepos = await listAllUserRepositories();
|
|
||||||
setRepositories(allRepos);
|
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -1257,7 +1247,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<GitMountEditor
|
<GitMountEditor
|
||||||
mounts={formData.git_mounts || []}
|
mounts={formData.git_mounts || []}
|
||||||
repositories={repositories}
|
|
||||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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,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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>>({});
|
||||||
|
|||||||
@@ -4131,3 +4131,28 @@ a.nav-item,
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-repo-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-form input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /repositories
|
||||||
|
|
||||||
|
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /repositories
|
||||||
|
|
||||||
|
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"ssh_key_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
|
||||||
|
| `remote_url` | `string` | No | Remote URL to clone from |
|
||||||
|
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
|
||||||
|
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
#### Success (201 Created)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "my-external-repo",
|
||||||
|
"path": "/data/repos/external/{user_id}/{repo_id}",
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"is_mirror": false,
|
||||||
|
"project_id": null,
|
||||||
|
"owner_id": "uuid",
|
||||||
|
"ssh_key_id": "uuid",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /projects/{project_id}/repositories
|
## GET /projects/{project_id}/repositories
|
||||||
|
|
||||||
**Description:** List repositories in a project.
|
**Description:** List repositories in a project.
|
||||||
|
|||||||
+35
-23
@@ -6,35 +6,34 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
|||||||
#### Scenario: Create profile with git mount
|
#### Scenario: Create profile with git mount
|
||||||
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
||||||
- **THEN** the profile stores each git mount with:
|
- **THEN** the profile stores each git mount with:
|
||||||
- `repo_id`: UUID of the referenced git repository
|
- `remote_url`: Direct git URL (e.g., "https://github.com/user/repo.git", "git@github.com:user/repo.git")
|
||||||
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
||||||
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
||||||
- `branch`: Optional branch or tag name (defaults to repository default branch)
|
- `branch`: Optional branch or tag name (defaults to "main")
|
||||||
|
|
||||||
#### Scenario: Git mount validation
|
#### Scenario: Git mount validation
|
||||||
- **WHEN** a profile with git mounts is saved
|
- **WHEN** a profile with git mounts is saved
|
||||||
- **THEN** the system validates that:
|
- **THEN** the system validates that:
|
||||||
- The referenced repository exists and belongs to the user's project
|
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
||||||
- `source_path` is a relative path (no leading `/`)
|
- `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)
|
||||||
|
|
||||||
#### Scenario: Profile with git mounts is resolved
|
#### Scenario: Profile with git mounts is resolved
|
||||||
- **GIVEN** a config profile with git mounts referencing repository "dotfiles"
|
- **GIVEN** a config profile with git mounts
|
||||||
- **WHEN** the profile is resolved for instance startup
|
- **WHEN** the profile is resolved for instance startup
|
||||||
- **THEN** the resolved profile includes the git mounts with repository details:
|
- **THEN** the resolved profile includes the git mounts as configured
|
||||||
- Repository filesystem path
|
- **AND** repository cloning happens at instance startup time, not at profile resolution
|
||||||
- Resolved branch name
|
|
||||||
- Source and target paths
|
|
||||||
|
|
||||||
#### Scenario: Git mount is applied at instance startup
|
#### Scenario: Git mount is applied at instance startup
|
||||||
- **GIVEN** a resolved profile with git mounts
|
- **GIVEN** a resolved profile with git mounts
|
||||||
- **WHEN** an instance is started with this profile
|
- **WHEN** an instance is started with this profile
|
||||||
- **THEN** for each git mount:
|
- **THEN** for each git mount:
|
||||||
- The repository filesystem path exists
|
- The repository is cloned from `remote_url` to a temporary location
|
||||||
- The source path within the repository exists
|
- The source path within the cloned repository exists
|
||||||
- A bind mount is created from `repo_path/source_path` to `container:target_path`
|
- A bind mount is created from `clone_path/source_path` to `container:target_path`
|
||||||
- **AND** if the repository or path is missing, a warning is logged and the mount is skipped
|
- **AND** if the clone fails or path is missing, a warning is logged and the mount is skipped
|
||||||
|
|
||||||
### Requirement: Git mounts support glob patterns
|
### Requirement: Git mounts support glob patterns
|
||||||
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
||||||
@@ -59,15 +58,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
|
|||||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||||
|
|
||||||
### Requirement: Git mounts trigger automatic cloning
|
### Requirement: Git mounts trigger automatic cloning
|
||||||
The system SHALL automatically clone referenced repositories if they do not exist locally.
|
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
||||||
|
|
||||||
#### Scenario: Repository not cloned at startup
|
#### Scenario: Repository cloned on container creation
|
||||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
- **GIVEN** a git mount with a `remote_url`
|
||||||
- **WHEN** the instance is started
|
- **WHEN** a new container is created with this profile
|
||||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
- **THEN** the system clones the repository from the URL to an instance-specific directory
|
||||||
- **AND** the clone proceeds asynchronously
|
- **AND** the clone proceeds as part of instance startup
|
||||||
- **AND** instance startup continues once clone completes
|
- **AND** instance startup continues once clone completes
|
||||||
|
|
||||||
|
#### Scenario: Existing clone updated on new container creation
|
||||||
|
- **GIVEN** a repository that was previously cloned for this instance
|
||||||
|
- **WHEN** a new container is created with this profile
|
||||||
|
- **THEN** the system pulls the latest updates from the remote_url
|
||||||
|
- **AND** checks out the specified branch (or default branch if not specified)
|
||||||
|
- **AND** uses the updated clone for the bind mount
|
||||||
|
|
||||||
#### Scenario: Clone failure handling
|
#### Scenario: Clone failure handling
|
||||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||||
- **WHEN** the instance attempts to clone
|
- **WHEN** the instance attempts to clone
|
||||||
@@ -76,6 +82,12 @@ The system SHALL automatically clone referenced repositories if they do not exis
|
|||||||
- **AND** the mount is skipped
|
- **AND** the mount is skipped
|
||||||
- **AND** instance startup continues with remaining mounts
|
- **AND** instance startup continues with remaining mounts
|
||||||
|
|
||||||
|
#### Scenario: Per-instance isolation
|
||||||
|
- **GIVEN** a git mount referencing a repository
|
||||||
|
- **WHEN** multiple instances are created using the same profile
|
||||||
|
- **THEN** each instance gets its own independent clone
|
||||||
|
- **AND** changes made in one container do not affect other containers
|
||||||
|
|
||||||
### Requirement: Git mounts support branch pinning
|
### Requirement: Git mounts support branch pinning
|
||||||
The system SHALL support pinning git mounts to specific branches or tags.
|
The system SHALL support pinning git mounts to specific branches or tags.
|
||||||
|
|
||||||
@@ -99,7 +111,7 @@ The system SHALL display git mounts in the config profile editor.
|
|||||||
- **GIVEN** a config profile with git mounts
|
- **GIVEN** a config profile with git mounts
|
||||||
- **WHEN** the user views the profile in the UI
|
- **WHEN** the user views the profile in the UI
|
||||||
- **THEN** the git mounts section displays each mount with:
|
- **THEN** the git mounts section displays each mount with:
|
||||||
- Repository name
|
- Git URL
|
||||||
- Source path within repository
|
- Source path within repository
|
||||||
- Target path in container
|
- Target path in container
|
||||||
- Branch/tag (if specified)
|
- Branch/tag (if specified)
|
||||||
@@ -107,10 +119,10 @@ The system SHALL display git mounts in the config profile editor.
|
|||||||
#### Scenario: Add git mount via UI
|
#### Scenario: Add git mount via UI
|
||||||
- **WHEN** a user adds a git mount in the profile editor
|
- **WHEN** a user adds a git mount in the profile editor
|
||||||
- **THEN** they can:
|
- **THEN** they can:
|
||||||
- Select from available repositories in the project
|
- Enter a git URL directly (https://, git@, or ssh://)
|
||||||
- Specify the source path (with autocomplete or validation)
|
- Specify the source path (with autocomplete or validation)
|
||||||
- Specify the target path in the container
|
- Specify the target path in the container
|
||||||
- Optionally select a branch/tag
|
- Optionally enter a branch/tag name
|
||||||
|
|
||||||
#### Scenario: Remove git mount via UI
|
#### Scenario: Remove git mount via UI
|
||||||
- **WHEN** a user removes a git mount from the profile editor
|
- **WHEN** a user removes a git mount from the profile editor
|
||||||
@@ -128,7 +140,7 @@ The system SHALL include git mounts in the profile preview/resolve output.
|
|||||||
- Source path (with expanded glob matches if applicable)
|
- Source path (with expanded glob matches if applicable)
|
||||||
- Target path in container
|
- Target path in container
|
||||||
- Resolved branch name
|
- Resolved branch name
|
||||||
- Clone status (exists, will clone, clone failed)
|
- Clone status (will clone on container creation)
|
||||||
|
|
||||||
#### Scenario: Preview warns about missing repository
|
#### Scenario: Preview warns about missing repository
|
||||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||||
|
|||||||
@@ -62,3 +62,21 @@
|
|||||||
- [x] 8.1 Update API documentation with new git_mounts fields
|
- [x] 8.1 Update API documentation with new git_mounts fields
|
||||||
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
||||||
- [x] 8.3 Document branch pinning behavior and fallback rules
|
- [x] 8.3 Document branch pinning behavior and fallback rules
|
||||||
|
|
||||||
|
## 9. External Repository Support
|
||||||
|
|
||||||
|
- [x] 9.1 Remove project requirement from git mount validation
|
||||||
|
- [x] 9.2 Add endpoint to create external repositories (no project_id)
|
||||||
|
- [x] 9.3 Update list_repositories endpoint to return all user repos
|
||||||
|
- [x] 9.4 Add endpoint to list external repositories
|
||||||
|
- [x] 9.5 Update spec: repos can be external (not tied to project)
|
||||||
|
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||||
|
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||||
|
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||||
|
|
||||||
|
## 10. UI Improvements
|
||||||
|
|
||||||
|
- [x] 10.1 Add ability to create external repositories from git mount editor
|
||||||
|
- [x] 10.2 Show "+ Add new repository..." option in repo dropdown
|
||||||
|
- [x] 10.3 Add form fields for repo name and remote URL
|
||||||
|
- [x] 10.4 Auto-refresh repo list after creating new repository
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user