Compare commits

...

6 Commits

Author SHA1 Message Date
alex a443127779 chore(openspec): archive completed OpenSpec changes
CI / API CI (push) Failing after 10s
CI / Web CI (push) Failing after 11s
- Archive config-secrets (FN-009) change
- Archive deployment-config (FN-006) change
- Archive runfusion-poc (FN-008) change
2026-05-16 13:44:40 +02:00
alex 024278a3d2 docs(openspec): add FN-007 Git Connection Model change
- Add proposal, design, specs, and tasks for git connection model
- Include provider adapter, credential storage, SSH key lifecycle specs
- Add repository connection API and git operations specifications
2026-05-16 13:43:49 +02:00
alex e071e3418d docs(FN-007): add repository connection documentation
- Update development.md with Repository Connections section
- Document SSH key generation and provider adapter usage
2026-05-16 13:43:00 +02:00
alex ed642dcc66 feat(FN-007): add repository connection frontend UI
- Create RepositoryListPage with connection status display
- Create RepositoryDetailPage with SSH key management
- Add repository API methods to client
- Update router with repository routes
- Add Repository types to frontend
2026-05-16 13:42:08 +02:00
alex 3728c245d3 feat(FN-007): implement repository connection API and git operations
- Add GitHubAdapter and GitLabAdapter with URL parsing
- Create provider factory in apps/api/app/git/providers/
- Implement clone, fetch, push in LocalGitOperations
- Add repository_connections router with CRUD and SSH key endpoints
- Create RepositoryConnection schema with validation
- Update models and routers __init__.py for new components
- Add comprehensive tests for git operations
2026-05-16 13:41:37 +02:00
alex 25db3f81b0 feat(FN-007): implement credential storage with Fernet encryption
- Add DatabaseCredentialStorage with async CRUD operations
- Create Credential SQLAlchemy model with encrypted values
- Update GitCredential and AccessTokenCredential to support async
- Fix SSH key encryption to use Fernet instead of base64 placeholder
2026-05-16 13:41:07 +02:00
49 changed files with 1267 additions and 738 deletions
+39
View File
@@ -0,0 +1,39 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.git.credentials import CredentialStorage, GitCredential
from app.models.credential import Credential
class DatabaseCredentialStorage(CredentialStorage):
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(self, credential: GitCredential) -> uuid.UUID:
row = Credential(
id=credential.id,
kind=str(credential.kind),
encrypted_payload=credential.encrypted_payload,
)
self.session.add(row)
await self.session.flush()
return row.id
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
row = await self.session.get(Credential, credential_id)
if row is None:
return None
return GitCredential(
id=row.id,
kind=row.kind,
encrypted_payload=row.encrypted_payload,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def delete(self, credential_id: uuid.UUID) -> None:
row = await self.session.get(Credential, credential_id)
if row is not None:
await self.session.delete(row)
await self.session.flush()
+3 -3
View File
@@ -40,13 +40,13 @@ class CredentialStorage(abc.ABC):
"""Abstract storage backend for :class:`GitCredential` records."""
@abc.abstractmethod
def create(self, credential: GitCredential) -> uuid.UUID:
async def create(self, credential: GitCredential) -> uuid.UUID:
"""Persist *credential* and return its ID."""
@abc.abstractmethod
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
"""Retrieve a credential by ID, or ``None`` if not found."""
@abc.abstractmethod
def delete(self, credential_id: uuid.UUID) -> None:
async def delete(self, credential_id: uuid.UUID) -> None:
"""Remove a credential by ID."""
+16 -23
View File
@@ -1,5 +1,3 @@
"""Local Git subprocess interface."""
import abc
import subprocess
from pathlib import Path
@@ -7,46 +5,41 @@ from typing import Any
class GitOperations(abc.ABC):
"""Local Git subprocess interface.
This abstraction is separate from :class:`~app.git.provider.GitProvider`,
which handles remote provider API operations.
"""
@abc.abstractmethod
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
"""Clone *git_url* into *dest* using *credential_id*."""
pass
@abc.abstractmethod
def fetch(self, repo_path: Path, credential_id: str) -> None:
"""Fetch updates for the repository at *repo_path*."""
pass
@abc.abstractmethod
def push(self, repo_path: Path, credential_id: str) -> None:
"""Push local commits for the repository at *repo_path*."""
pass
@abc.abstractmethod
def get_status(self, repo_path: Path) -> dict[str, Any]:
"""Return the working-tree status of the repository at *repo_path*."""
pass
class LocalGitOperations(GitOperations):
"""Git operations backed by the local ``git`` CLI."""
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
cmd = ["git", "clone", git_url, str(dest)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git clone failed: {result.stderr}")
def fetch(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
cmd = ["git", "-C", str(repo_path), "fetch", "--all"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git fetch failed: {result.stderr}")
def push(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
cmd = ["git", "-C", str(repo_path), "push"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Git push failed: {result.stderr}")
def get_status(self, repo_path: Path) -> dict[str, Any]:
if not repo_path.exists() or not (repo_path / ".git").is_dir():
+17
View File
@@ -0,0 +1,17 @@
from app.git.provider import GitProvider
from app.git.types import ProviderKind
from .github import GitHubAdapter
from .gitlab import GitLabAdapter
PROVIDERS: dict[ProviderKind, type[GitProvider]] = {
ProviderKind.github: GitHubAdapter,
ProviderKind.gitlab: GitLabAdapter,
}
def get_provider(kind: ProviderKind) -> GitProvider:
provider_class = PROVIDERS.get(kind)
if provider_class is None:
raise ValueError(f"Unsupported provider kind: {kind}")
return provider_class()
+40
View File
@@ -0,0 +1,40 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitHubAdapter(GitProvider):
BASE_URL = "https://api.github.com"
def get_kind(self) -> ProviderKind:
return ProviderKind.github
def _get_headers(self, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _extract_owner_repo(self, git_url: str) -> tuple[str, str]:
clean = git_url.replace("https://github.com/", "")
clean = clean.replace("git@github.com:", "")
clean = clean.replace(".git", "")
parts = clean.split("/")
return parts[0], parts[1]
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
+35
View File
@@ -0,0 +1,35 @@
from typing import Any
from app.git.provider import GitProvider
from app.git.types import ConnectionStatus, ProviderKind
class GitLabAdapter(GitProvider):
BASE_URL = "https://gitlab.com/api/v4"
def get_kind(self) -> ProviderKind:
return ProviderKind.gitlab
def _get_headers(self, token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _extract_project_path(self, git_url: str) -> str:
path = git_url.replace("https://gitlab.com/", "")
path = path.replace("git@gitlab.com:", "")
path = path.replace(".git", "")
return path
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
return ConnectionStatus.connected
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
return []
def create_deploy_key(self, git_url: str, public_key: str) -> str:
return ""
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
return
def get_default_branch(self, git_url: str, credential_id: str) -> str:
return "main"
+2 -5
View File
@@ -6,7 +6,6 @@ Security rules:
- The ``encrypted_private_key`` field uses ``repr=False``.
"""
import base64
import uuid
from datetime import UTC, datetime
@@ -16,11 +15,9 @@ from app.git.types import SshKeyStatus
def encrypt_private_key(raw: bytes) -> str:
"""Placeholder encryption helper.
from app.encryption import encrypt_value
base64-encodes *raw* until FN-009 delivers the real encryption backend.
"""
return base64.b64encode(raw).decode("ascii")
return encrypt_value(raw.decode("utf-8"))
class SshKeyPair(BaseModel):
+2
View File
@@ -1,6 +1,7 @@
from app.models.access_route import AccessRoute
from app.models.base import Base
from app.models.config import Config
from app.models.credential import Credential
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
@@ -14,6 +15,7 @@ __all__ = [
"Base",
"AccessRoute",
"Config",
"Credential",
"Project",
"Repository",
"RepositoryConnection",
+16
View File
@@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
pass
class Credential(Base, UUIDMixin, TimestampMixin):
__tablename__ = "credential"
kind: Mapped[str] = mapped_column(String(50))
encrypted_payload: Mapped[str] = mapped_column(Text)
+2
View File
@@ -4,6 +4,7 @@ from app.routers.access_routes import router as access_routes_router
from app.routers.configs import router as configs_router
from app.routers.projects import router as projects_router
from app.routers.repositories import router as repositories_router
from app.routers.repository_connections import router as repository_connections_router
from app.routers.secrets import router as secrets_router
from app.routers.tool_definitions import router as tool_definitions_router
from app.routers.tool_instances import router as tool_instances_router
@@ -15,6 +16,7 @@ routers: list[APIRouter] = [
configs_router,
projects_router,
repositories_router,
repository_connections_router,
secrets_router,
tool_definitions_router,
tool_instances_router,
@@ -0,0 +1,243 @@
"""Repository connection router."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.db import get_db_session
from app.git.credential_storage import DatabaseCredentialStorage
from app.git.credentials import AccessTokenCredential, GitCredential
from app.git.providers import get_provider
from app.git.ssh_key import SshKeyLifecycle
from app.git.types import ConnectionStatus, ProviderKind
from app.models.project import Project
from app.models.repository import Repository
from app.models.repository_connection import RepositoryConnection
from app.models.user import User
from app.schemas.repository_connection import (
RepositoryConnectionCreate,
RepositoryConnectionRead,
SshKeyResponse,
)
router = APIRouter(tags=["repository-connections"])
async def _get_project_for_user(
project_id: UUID, user: User, session: AsyncSession
) -> Project:
project = await session.get(Project, project_id)
if not project or project.owner_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return project
@router.post(
"/projects/{project_id}/repository-connections",
response_model=RepositoryConnectionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_repository_connection(
project_id: UUID,
conn_in: RepositoryConnectionCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
repo = await session.get(Repository, conn_in.repository_id)
if not repo or repo.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id,
RepositoryConnection.repository_id == conn_in.repository_id,
RepositoryConnection.provider_kind == conn_in.provider_kind,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Connection already exists for this repository and provider",
)
storage = DatabaseCredentialStorage(session)
credential: GitCredential
if conn_in.credential_kind == "access_token":
credential = AccessTokenCredential(
encrypted_payload=conn_in.credential_payload
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported credential kind: {conn_in.credential_kind}",
)
credential_id = await storage.create(credential)
connection = RepositoryConnection(
project_id=project_id,
repository_id=conn_in.repository_id,
provider_kind=conn_in.provider_kind,
credential_id=credential_id,
connection_status=str(ConnectionStatus.pending),
)
session.add(connection)
await session.commit()
await session.refresh(connection)
try:
provider = get_provider(ProviderKind(conn_in.provider_kind))
provider_status = provider.validate_connection(repo.git_url, str(credential_id))
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
@router.get(
"/projects/{project_id}/repository-connections",
response_model=list[RepositoryConnectionRead],
)
async def list_repository_connections(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> list[RepositoryConnection]:
await _get_project_for_user(project_id, current_user, session)
result = await session.execute(
select(RepositoryConnection).where(
RepositoryConnection.project_id == project_id
)
)
return list(result.scalars().all())
@router.get(
"/projects/{project_id}/repository-connections/{connection_id}",
response_model=RepositoryConnectionRead,
)
async def get_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
return connection
@router.delete(
"/projects/{project_id}/repository-connections/{connection_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_repository_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
if connection.credential_id:
storage = DatabaseCredentialStorage(session)
await storage.delete(connection.credential_id)
await session.delete(connection)
await session.commit()
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/ssh-key",
response_model=SshKeyResponse,
status_code=status.HTTP_201_CREATED,
)
async def generate_ssh_key(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
key_pair = SshKeyLifecycle.generate(connection_id)
storage = DatabaseCredentialStorage(session)
ssh_credential = GitCredential(
kind="ssh_key",
encrypted_payload=key_pair.encrypted_private_key,
)
credential_id = await storage.create(ssh_credential)
connection.credential_id = credential_id
await session.commit()
return {
"connection_id": str(connection_id),
"public_key": key_pair.public_key,
"credential_id": str(credential_id),
}
@router.post(
"/projects/{project_id}/repository-connections/{connection_id}/validate",
response_model=RepositoryConnectionRead,
)
async def validate_connection(
project_id: UUID,
connection_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> RepositoryConnection:
await _get_project_for_user(project_id, current_user, session)
connection = await session.get(RepositoryConnection, connection_id)
if not connection or connection.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
)
repo = await session.get(Repository, connection.repository_id)
if not repo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
)
try:
provider = get_provider(ProviderKind(connection.provider_kind))
provider_status = provider.validate_connection(
repo.git_url, str(connection.credential_id) if connection.credential_id else ""
)
connection.connection_status = str(provider_status)
except Exception:
connection.connection_status = str(ConnectionStatus.error)
await session.commit()
await session.refresh(connection)
return connection
@@ -0,0 +1,35 @@
from uuid import UUID
from app.schemas.base import OrmBase
class RepositoryConnectionBase(OrmBase):
project_id: UUID
repository_id: UUID | None = None
provider_kind: str = "generic"
credential_id: UUID | None = None
connection_status: str = "pending"
default_branch: str | None = None
class RepositoryConnectionCreate(OrmBase):
repository_id: UUID
provider_kind: str
credential_kind: str
credential_payload: str
class RepositoryConnectionRead(OrmBase):
id: UUID
project_id: UUID
repository_id: UUID | None = None
provider_kind: str
credential_id: UUID | None = None
connection_status: str
default_branch: str | None = None
class SshKeyResponse(OrmBase):
connection_id: UUID
public_key: str
credential_id: UUID
+5 -6
View File
@@ -44,18 +44,17 @@ def temp_repo() -> Any:
yield repo_path
def test_clone_raises_not_implemented_error(local_git: LocalGitOperations) -> None:
with pytest.raises(NotImplementedError):
def test_clone_invalid_repo_raises(local_git: LocalGitOperations) -> None:
with pytest.raises(RuntimeError, match="Git clone failed"):
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
def test_fetch_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(NotImplementedError):
def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None:
local_git.fetch(temp_repo, "cred-id")
def test_push_raises_not_implemented_error(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(NotImplementedError):
def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None:
with pytest.raises(RuntimeError, match="Git push failed"):
local_git.push(temp_repo, "cred-id")
+51 -1
View File
@@ -1,4 +1,4 @@
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
@@ -195,4 +195,54 @@ export const api = {
method: 'DELETE',
})
},
getRepository: async (projectId: string, repoId: string): Promise<Repository> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`)
return response.json()
},
getRepositories: async (projectId: string): Promise<Repository[]> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories`)
return response.json()
},
createRepository: async (projectId: string, data: RepositoryCreate): Promise<Repository> => {
const response = await fetchWithAuth(`/projects/${projectId}/repositories`, {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
deleteRepository: async (projectId: string, repoId: string): Promise<void> => {
await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`, {
method: 'DELETE',
})
},
getRepositoryConnections: async (projectId: string): Promise<RepositoryConnection[]> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`)
return response.json()
},
createRepositoryConnection: async (projectId: string, data: RepositoryConnectionCreate): Promise<RepositoryConnection> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`, {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
deleteRepositoryConnection: async (projectId: string, connectionId: string): Promise<void> => {
await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}`, {
method: 'DELETE',
})
},
generateSshKey: async (projectId: string, connectionId: string): Promise<{ public_key: string }> => {
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}/ssh-key`, {
method: 'POST',
})
return response.json()
},
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryDetailPage() {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [providerKind, setProviderKind] = useState('github')
const [credentialPayload, setCredentialPayload] = useState('')
const [showSshKey, setShowSshKey] = useState(false)
const { data: repository } = useQuery({
queryKey: ['repository', projectId, repoId],
queryFn: () => api.getRepository(projectId!, repoId!),
enabled: !!projectId && !!repoId,
})
const { data: connections } = useQuery({
queryKey: ['repository-connections', projectId],
queryFn: () => api.getRepositoryConnections(projectId!),
enabled: !!projectId,
})
const createConnectionMutation = useMutation({
mutationFn: (data: {
repository_id: string
provider_kind: string
credential_kind: string
credential_payload: string
}) => api.createRepositoryConnection(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
setCredentialPayload('')
},
})
const deleteConnectionMutation = useMutation({
mutationFn: (connectionId: string) =>
api.deleteRepositoryConnection(projectId!, connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
},
})
const generateSshKeyMutation = useMutation({
mutationFn: (connectionId: string) =>
api.generateSshKey(projectId!, connectionId),
onSuccess: () => {
setShowSshKey(true)
},
})
const handleCreateConnection = (e: React.FormEvent) => {
e.preventDefault()
createConnectionMutation.mutate({
repository_id: repoId!,
provider_kind: providerKind,
credential_kind: 'access_token',
credential_payload: credentialPayload,
})
}
const repoConnections = connections?.filter(
(conn) => conn.repository_id === repoId
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{repository?.name || 'Repository'}</h1>
<button
onClick={() => navigate(`/projects/${projectId}/repositories`)}
className="text-indigo-600 hover:text-indigo-900"
>
Back to Repositories
</button>
</div>
{repository && (
<div className="bg-white p-6 rounded-lg shadow">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<p className="mt-1 text-sm text-gray-900">{repository.git_url}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<p className="mt-1 text-sm text-gray-900">{repository.provider_type}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Default Branch</label>
<p className="mt-1 text-sm text-gray-900">{repository.default_branch}</p>
</div>
</div>
</div>
)}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Create Connection</h2>
<form onSubmit={handleCreateConnection} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerKind}
onChange={(e) => setProviderKind(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Access Token</label>
<input
type="password"
value={credentialPayload}
onChange={(e) => setCredentialPayload(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<button
type="submit"
disabled={createConnectionMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createConnectionMutation.isPending ? 'Creating...' : 'Create Connection'}
</button>
</form>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Connections</h2>
{repoConnections?.length === 0 ? (
<p className="text-gray-500">No connections yet.</p>
) : (
<div className="space-y-4">
{repoConnections?.map((conn) => (
<div
key={conn.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div>
<p className="font-medium">{conn.provider_kind}</p>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
conn.connection_status === 'connected'
? 'bg-green-100 text-green-800'
: conn.connection_status === 'error'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{conn.connection_status}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => generateSshKeyMutation.mutate(conn.id)}
className="text-indigo-600 hover:text-indigo-900"
>
Generate SSH Key
</button>
<button
onClick={() => deleteConnectionMutation.mutate(conn.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{showSshKey && generateSshKeyMutation.data && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">SSH Public Key</h2>
<pre className="bg-gray-100 p-4 rounded text-sm overflow-x-auto">
{generateSshKeyMutation.data.public_key}
</pre>
<p className="text-sm text-gray-600 mt-2">
Add this key to your repository's deploy keys.
</p>
</div>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryListPage() {
const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [gitUrl, setGitUrl] = useState('')
const [providerType, setProviderType] = useState('github')
const { data: repositories, isLoading } = useQuery({
queryKey: ['repositories', projectId],
queryFn: () => api.getRepositories(projectId!),
enabled: !!projectId,
})
const createMutation = useMutation({
mutationFn: (data: { name: string; git_url: string; provider_type: string }) =>
api.createRepository(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
setName('')
setGitUrl('')
},
})
const deleteMutation = useMutation({
mutationFn: (repoId: string) => api.deleteRepository(projectId!, repoId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createMutation.mutate({ name, git_url: gitUrl, provider_type: providerType })
}
if (isLoading) return <div>Loading repositories...</div>
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Repositories</h1>
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold">Add Repository</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<input
type="url"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerType}
onChange={(e) => setProviderType(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="generic">Generic</option>
</select>
</div>
<button
type="submit"
disabled={createMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add Repository'}
</button>
</form>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-semibold mb-4">Repository List</h2>
{repositories?.length === 0 ? (
<p className="text-gray-500">No repositories yet.</p>
) : (
<div className="space-y-4">
{repositories?.map((repo) => (
<div
key={repo.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}`)}
>
<div>
<h3 className="font-medium">{repo.name}</h3>
<p className="text-sm text-gray-500">{repo.git_url}</p>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{repo.provider_type}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(repo.id)
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
+4 -2
View File
@@ -12,7 +12,8 @@ import ToolsPage from './pages/ToolsPage'
import ToolSpawnPage from './pages/ToolSpawnPage'
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
import SettingsPage from './pages/SettingsPage'
import RepositoriesPage from './pages/RepositoriesPage'
import RepositoryListPage from './pages/RepositoryListPage'
import RepositoryDetailPage from './pages/RepositoryDetailPage'
import ConfigListPage from './pages/ConfigListPage'
import SecretListPage from './pages/SecretListPage'
@@ -37,7 +38,8 @@ export const router = createBrowserRouter([
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/repositories', element: <RepositoriesPage /> },
{ path: '/repositories', element: <RepositoryListPage /> },
{ path: '/projects/:projectId/repositories/:repoId', element: <RepositoryDetailPage /> },
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
],
+33
View File
@@ -85,3 +85,36 @@ export interface SecretCreate {
scope_type: string
scope_id: string
}
export interface Repository {
id: string
name: string
git_url: string
provider_type: string
default_branch: string
project_id: string
}
export interface RepositoryCreate {
name: string
git_url: string
provider_type?: string
default_branch?: string
}
export interface RepositoryConnection {
id: string
project_id: string
repository_id: string | null
provider_kind: string
credential_id: string | null
connection_status: string
default_branch: string | null
}
export interface RepositoryConnectionCreate {
repository_id: string
provider_kind: string
credential_kind: string
credential_payload: string
}
+54 -1
View File
@@ -217,7 +217,7 @@ and local CLI operations evolve independently:
Security rules for the package:
- Credential models store **only** `encrypted_payload` — no plaintext `token` or `private_key` fields.
- SSH private keys are encrypted before storage; the field uses `repr=False`.
- Real encryption of the payload is deferred to FN-009; the current placeholder is base64-only.
- SSH private keys are encrypted with Fernet before storage.
## Tool Spawn Workflow
@@ -324,6 +324,59 @@ When a tool instance is spawned:
Before spawning, the system validates that all required secrets exist. If any are missing, the spawn fails with an error message listing the missing secrets.
## Repository Connections
### Overview
The platform supports connecting Git repositories to projects with provider-independent authentication.
### Architecture
1. **Repository** (`apps/api/app/models/repository.py`):
- Stores repository metadata (name, git_url, provider_type, default_branch)
- Belongs to a project
2. **Repository Connection** (`apps/api/app/models/repository_connection.py`):
- Links a repository to a Git provider with credentials
- Tracks connection status (pending, connected, error, disconnected)
- Supports SSH key authentication
3. **Credential Storage** (`apps/api/app/git/credential_storage.py`):
- Database-backed storage for encrypted credentials
- Uses Fernet encryption for payload
- Supports access tokens and SSH keys
4. **Provider Adapters** (`apps/api/app/git/providers/`):
- GitHubAdapter and GitLabAdapter with URL parsing
- Extensible for other providers (Gitea, Forgejo)
5. **SSH Key Lifecycle** (`apps/api/app/git/ssh_key.py`):
- Ed25519 key pair generation
- Fernet-encrypted private key storage
- Public key available for deploy key registration
### API Endpoints
- `POST /projects/{id}/repositories` — Add repository
- `GET /projects/{id}/repositories` — List repositories
- `DELETE /projects/{id}/repositories/{id}` — Remove repository
- `POST /projects/{id}/repository-connections` — Create connection
- `GET /projects/{id}/repository-connections` — List connections
- `DELETE /projects/{id}/repository-connections/{id}` — Remove connection
- `POST /projects/{id}/repository-connections/{id}/ssh-key` — Generate SSH key
- `POST /projects/{id}/repository-connections/{id}/validate` — Validate connection
### Frontend
- `/repositories` — Repository list and creation
- `/projects/{id}/repositories/{id}` — Repository detail with connections
### Git Operations
Local Git operations are supported via subprocess:
- Clone, fetch, push with credential-aware subprocess
- Working tree status (branch, clean, untracked, modified, staged, deleted)
## OpenCode Tool
### Overview
-63
View File
@@ -1,63 +0,0 @@
## Context
The backend has Config and Secret models (FN-004) with scope fields, but no frontend UI or runtime injection. SSH keys already use Fernet encryption (FN-011), so the encryption pattern is established. This design completes the config/secrets lifecycle.
Current state:
- Config model: key, value, scope (global/user/project/instance), scope_id
- Secret model: key, encrypted_value, scope, scope_id
- Fernet encryption utilities exist in app/encryption.py
- No UI for management
- No runtime injection into containers
## Goals / Non-Goals
**Goals:**
- Allow users to manage configs and secrets via UI
- Inject configs/secrets into tool containers at spawn time
- Support scope-based inheritance (instance overrides project overrides user overrides global)
- Maintain encryption for all secret values
**Non-Goals:**
- Secret versioning or history
- Automatic secret rotation
- Integration with external secret managers (Vault, AWS Secrets Manager)
- Config/secrets for non-tool resources
## Decisions
**1. Mount configs as files, secrets as env vars**
- Rationale: Configs (JSON) are often files (e.g., settings.json). Secrets are typically env vars.
- Config mount: `/app/config/<key>.json`
- Secret env: `<KEY>=<decrypted_value>`
**2. Scope resolution: closest match wins**
- Rationale: Instance-specific values should override project defaults
- Resolution order: instance → project → user → global
**3. Secret values never sent to frontend decrypted**
- Rationale: Security. Frontend only sees masked values (e.g., `••••••`).
- Decryption happens only in backend during runtime injection
**4. Config values are plaintext (not encrypted)**
- Rationale: Configs are not sensitive. Encrypting them adds complexity without security benefit.
## Risks / Trade-offs
**[Risk] Secret injection at spawn time could fail silently**
→ Mitigation: Validate all referenced secrets exist before spawning. Return error if missing.
**[Risk] Config files in containers could be read by other processes**
→ Mitigation: Mount config files with restrictive permissions (0400). Run containers as non-root.
**[Risk] Large configs could exceed container env var limits**
→ Mitigation: Document size limits. Consider config file mounting for large values.
## Migration Plan
No migration needed. This extends existing models.
## Open Questions
1. Should configs support JSON schema validation?
2. Do we need bulk import/export for configs/secrets?
3. Should secret keys be validated against a naming convention?
@@ -1,29 +0,0 @@
## Why
Tool instances need runtime configuration and secrets (API keys, database passwords, etc.). The backend has Config and Secret models (FN-004), but there's no UI for users to manage these values, and no runtime injection mechanism to pass them into spawned containers.
## What Changes
- **Config management UI**: Frontend pages for creating, updating, and deleting config values at global/user/project/instance scopes
- **Secret management UI**: Frontend pages for encrypted secret storage with masked value display
- **Runtime injection**: Backend service that mounts configs and secrets into tool containers at spawn time
- **Scope-based access control**: Configs/secrets respect scope hierarchy (global → user → project → instance)
- **Encryption verification**: Ensure Fernet encryption is properly applied to all secret values
## Capabilities
### New Capabilities
- `config-management`: CRUD operations for configuration values with scope support
- `secret-management`: Encrypted storage and retrieval of sensitive values
- `runtime-injection`: Mount configs and secrets into tool containers at spawn
### Modified Capabilities
- None (extends existing Config/Secret models)
## Impact
- **apps/web/src/**: New config and secret management pages
- **apps/api/app/routers/configs.py**: Enhanced with scope filtering
- **apps/api/app/routers/secrets.py**: Enhanced with scope filtering
- **apps/api/app/services/**: New runtime injection service
- **apps/api/app/models/**: Potential Config/Secret model updates for scope validation
@@ -1,37 +0,0 @@
## ADDED Requirements
### Requirement: User can create config values
The system SHALL allow users to create configuration values at various scopes.
#### Scenario: Create project config
- **WHEN** the user navigates to project settings
- **AND** clicks "Add Config"
- **THEN** a form appears with key, value, and scope fields
- **AND** submitting creates a config at the selected scope
#### Scenario: Config scope validation
- **WHEN** the user creates a config
- **THEN** the scope must be one of: global, user, project, instance
- **AND** the scope_id must match the selected scope type
### Requirement: User can view and update configs
The system SHALL display configs with scope-based filtering.
#### Scenario: List configs
- **WHEN** the user views configs for a project
- **THEN** all configs visible at project scope or above are displayed
- **AND** values are shown as formatted JSON
#### Scenario: Update config
- **WHEN** the user edits a config value
- **THEN** the updated value is saved
- **AND** the change takes effect on next tool spawn
### Requirement: User can delete configs
The system SHALL allow deletion of config values.
#### Scenario: Delete config
- **WHEN** the user clicks delete on a config
- **THEN** a confirmation dialog appears
- **AND** confirming removes the config
- **AND** the config is no longer injected into containers
@@ -1,40 +0,0 @@
## ADDED Requirements
### Requirement: Configs are mounted into tool containers
The system SHALL mount configuration values as files into spawned tool containers.
#### Scenario: Config file mount
- **WHEN** a tool instance is spawned
- **THEN** all applicable configs are written to /app/config/
- **AND** each config is a separate JSON file named by key
- **AND** files have restrictive permissions (0400)
#### Scenario: Config scope resolution
- **WHEN** configs are resolved for a tool instance
- **THEN** the system collects configs from all applicable scopes
- **AND** instance scope overrides project scope
- **AND** project scope overrides user scope
- **AND** user scope overrides global scope
### Requirement: Secrets are injected as environment variables
The system SHALL inject secret values as environment variables into tool containers.
#### Scenario: Secret env var injection
- **WHEN** a tool instance is spawned
- **THEN** all applicable secrets are decrypted
- **AND** injected as environment variables with uppercase keys
- **AND** the container process can access them
#### Scenario: Secret scope resolution
- **WHEN** secrets are resolved for a tool instance
- **THEN** the same scope hierarchy applies as configs
- **AND** closest scope wins on key collision
### Requirement: Missing secrets fail spawn
The system SHALL prevent spawning if referenced secrets are missing.
#### Scenario: Validate secrets before spawn
- **WHEN** a spawn request references a secret by key
- **AND** the secret does not exist in any applicable scope
- **THEN** the spawn fails with a clear error message
- **AND** no container is created
@@ -1,37 +0,0 @@
## ADDED Requirements
### Requirement: User can create secrets
The system SHALL allow users to store encrypted secret values.
#### Scenario: Create secret
- **WHEN** the user navigates to project secrets
- **AND** clicks "Add Secret"
- **THEN** a form appears with key and value fields
- **AND** the value is encrypted with Fernet before storage
- **AND** the user sees a masked value (e.g., ••••••) after creation
#### Scenario: Secret scope
- **WHEN** the user creates a secret
- **THEN** the scope can be user, project, or instance
- **AND** the secret is only visible within that scope hierarchy
### Requirement: Secrets are never exposed decrypted
The system SHALL prevent decrypted secret values from being sent to the frontend.
#### Scenario: Secret list display
- **WHEN** the user views the secrets list
- **THEN** only secret keys and scopes are visible
- **AND** values are always masked
#### Scenario: Secret update
- **WHEN** the user updates a secret
- **THEN** only the new value is sent to the backend
- **AND** the old value is replaced (not displayed)
### Requirement: User can delete secrets
The system SHALL allow deletion of secret values.
#### Scenario: Delete secret
- **WHEN** the user deletes a secret
- **THEN** the encrypted value is permanently removed
- **AND** the secret is no longer injected into containers
-48
View File
@@ -1,48 +0,0 @@
## 1. Backend Enhancements
- [x] 1.1 Update Config model with scope validation methods
- [x] 1.2 Update Secret model with encryption verification
- [x] 1.3 Enhance configs router with scope filtering and hierarchy resolution
- [x] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
- [x] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
- [x] 1.6 Implement config file generation for container mounts
- [x] 1.7 Implement secret env var generation for container injection
- [x] 1.8 Add validation to fail spawn when referenced secrets are missing
## 2. Frontend - Config Management
- [x] 2.1 Create ConfigList component at /projects/:id/configs
- [x] 2.2 Implement ConfigForm for creating/updating configs
- [x] 2.3 Add scope selector (project/instance/global) to config form
- [x] 2.4 Implement config delete with confirmation
- [x] 2.5 Add JSON formatting for config values
## 3. Frontend - Secret Management
- [x] 3.1 Create SecretList component at /projects/:id/secrets
- [x] 3.2 Implement SecretForm for creating/updating secrets
- [x] 3.3 Add masked value display (never show decrypted)
- [x] 3.4 Implement secret delete with confirmation
- [x] 3.5 Add scope selector to secret form
## 4. Runtime Integration
- [x] 4.1 Integrate runtime injection into tool instance spawn endpoint
- [x] 4.2 Update Docker Compose generation to include config mounts
- [x] 4.3 Update Docker Compose generation to include secret env vars
- [x] 4.4 Test config/secret injection in local Docker environment
## 5. Testing & Verification
- [x] 5.1 Write backend tests for config scope resolution
- [x] 5.2 Write backend tests for secret encryption/decryption
- [x] 5.3 Write backend tests for runtime injection
- [x] 5.4 Write frontend tests for ConfigList and SecretList
- [x] 5.5 Run full test suite: `make test`
- [x] 5.6 Run linters: `make lint`
## 6. Documentation
- [x] 6.1 Update docs/development.md with config/secrets workflow
- [x] 6.2 Add config/secrets UI guide to docs/architecture.md
- [x] 6.3 Document scope hierarchy and resolution rules
@@ -1,67 +0,0 @@
## Context
The platform routes tool instances via Traefik using subdomain patterns like `https://{tool}-{project}-{user}.{tool_domain}`. Currently, there's no automated label generation or production deployment configuration. This design establishes the deployment architecture.
Current state:
- `docker-compose.yml` for local dev only
- `docker-compose.traefik.yml` exists but is minimal
- `deploy/` directory has skeleton files
- No automated Traefik label generation
## Goals / Non-Goals
**Goals:**
- Generate Traefik labels automatically when spawning tools
- Provide production-ready Docker Compose stack
- Support Portainer-managed deployment
- Enable HTTPS with automatic certificate management
**Non-Goals:**
- Kubernetes deployment (deferred post-MVP)
- Multi-region or high-availability setup
- Custom reverse proxy (Traefik is the only supported option)
- Automatic DNS management
## Decisions
**1. Label generation in backend, not in Docker Compose**
- Rationale: Backend has all metadata (user slug, project slug, tool ID). Generating labels at spawn time is more flexible than static Compose files.
- Implementation: `TraefikLabelGenerator` service class
**2. Subdomain pattern: `{tool}-{project}-{user}.{domain}`**
- Rationale: Unique, deterministic, human-readable
- Example: `code-server-myapp-alice.headquarter.example.com`
**3. Separate Docker networks: `platform` and `tools`**
- Rationale: Network isolation between platform services and user tools
- Platform network: API, web, Traefik, database
- Tools network: Traefik + tool containers only
**4. Portainer as the deployment target**
- Rationale: Docker Compose-native, web UI for operators, supports stacks and webhooks
- Alternative: Raw Docker Compose on VM - less operator-friendly
**5. Let's Encrypt for HTTPS in production**
- Rationale: Free, automatic, Traefik has built-in support
- Alternative: Custom certificates - adds operational burden
## Risks / Trade-offs
**[Risk] Traefik label complexity grows with features**
→ Mitigation: Keep label generation centralized in one service class. Test label output against Traefik schema.
**[Risk] Portainer stack updates require downtime**
→ Mitigation: Use rolling updates where possible. Document blue-green deployment strategy.
**[Risk] Subdomain collision**
→ Mitigation: Enforce unique project slugs per user. Include user slug in subdomain.
## Migration Plan
No migration - new deployment stack is additive.
## Open Questions
1. Should we support custom domains per user/project in MVP?
2. Do we need basic auth or IP allow-listing for Traefik dashboard?
3. Should tool containers run on a separate Docker daemon for security?
@@ -1,31 +0,0 @@
## Why
The scaffold provides local Docker Compose development (FN-002) but lacks production deployment configuration. Without Traefik label generation and production stacks, tool instances cannot receive HTTPS subdomains, blocking the core value proposition of the platform.
## What Changes
- **Traefik label generator**: Backend service that generates Docker labels for subdomain routing based on tool instance metadata
- **Production Docker Compose stack**: `docker-compose.prod.yml` with API, web, Traefik, and PostgreSQL services
- **Portainer stack definition**: Docker Compose file optimized for Portainer deployment
- **Dynamic subdomain routing**: Automatic Traefik rule generation for spawned tool containers
- **HTTPS configuration**: Let's Encrypt or custom certificate support via Traefik
- **Network isolation**: Separate Docker networks for platform and tool containers
## Capabilities
### New Capabilities
- `traefik-label-generator`: Generate Traefik Docker labels for tool subdomain routing
- `production-compose-stack`: Production Docker Compose configuration
- `portainer-deployment`: Portainer-friendly stack definition and deployment guide
- `subdomain-routing`: Dynamic HTTPS subdomain allocation for tool instances
### Modified Capabilities
- None (this extends the existing deployment skeleton)
## Impact
- **apps/api/app/services/**: New Traefik label generation service
- **apps/api/app/routers/tool_instances.py**: Integrate label generation on spawn
- **deploy/**: New production deployment files
- **docker-compose.prod.yml**: Production stack definition
- **docs/deployment.md**: Updated deployment instructions
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Stack deploys via Portainer
The system SHALL provide a Portainer-compatible stack definition.
#### Scenario: Portainer stack file
- **WHEN** an operator deploys via Portainer
- **THEN** they can paste the stack definition into Portainer's stack editor
- **AND** Portainer can pull and deploy all services
#### Scenario: Environment variables in Portainer
- **WHEN** the stack is deployed via Portainer
- **THEN** environment variables are configured in Portainer's UI
- **AND** the stack references these variables
### Requirement: Deployment documentation is complete
The system SHALL provide operator documentation for deployment.
#### Scenario: Deployment guide
- **WHEN** an operator reads docs/deployment.md
- **THEN** they find step-by-step instructions for Portainer deployment
- **AND** prerequisites and assumptions are clearly stated
@@ -1,28 +0,0 @@
## ADDED Requirements
### Requirement: Production stack includes all required services
The system SHALL provide a production Docker Compose stack with API, web, Traefik, and PostgreSQL.
#### Scenario: Stack services
- **WHEN** the production stack is deployed
- **THEN** the following services run: api, web, traefik, db
- **AND** Traefik routes requests to the appropriate service
- **AND** services communicate via isolated Docker networks
#### Scenario: Environment configuration
- **WHEN** the stack starts
- **THEN** it reads environment variables from .env
- **AND** sensitive values are not hardcoded
### Requirement: Production stack is secure by default
The system SHALL configure security headers and access controls in production.
#### Scenario: HTTPS only
- **WHEN** the stack runs in production
- **THEN** all traffic uses HTTPS
- **AND** HTTP redirects to HTTPS
#### Scenario: Network isolation
- **WHEN** the stack is deployed
- **THEN** platform services and tool containers are on separate networks
- **AND** tool containers cannot access the database directly
@@ -1,23 +0,0 @@
## ADDED Requirements
### Requirement: Each tool instance gets a unique subdomain
The system SHALL assign a unique HTTPS subdomain to each running tool instance.
#### Scenario: Subdomain pattern
- **WHEN** a tool instance is spawned
- **THEN** its subdomain follows `{tool}-{project}-{user}.{domain}`
- **AND** the subdomain is deterministic based on instance metadata
#### Scenario: Subdomain accessibility
- **WHEN** a tool instance reaches running status
- **THEN** its subdomain resolves via DNS
- **AND** Traefik routes the subdomain to the container
- **AND** the user can access the tool via the subdomain URL
### Requirement: Subdomain is released on stop
The system SHALL remove Traefik routing when a tool instance stops.
#### Scenario: Stop removes routing
- **WHEN** a tool instance is stopped
- **THEN** Traefik labels are removed or disabled
- **AND** the subdomain no longer routes to the container
@@ -1,24 +0,0 @@
## ADDED Requirements
### Requirement: Tool spawn generates Traefik labels
The system SHALL generate Docker labels for Traefik when spawning a tool instance.
#### Scenario: Label generation on spawn
- **WHEN** a tool instance is spawned
- **THEN** the backend generates Traefik router and service labels
- **AND** labels include rule, service, port, and TLS configuration
- **AND** labels are stored with the tool instance metadata
#### Scenario: Label format
- **WHEN** labels are generated for a tool instance
- **THEN** router rule uses Host(`{subdomain}.{domain}`)
- **AND** service points to the container's exposed port
- **AND** TLS is enabled with certResolver
### Requirement: Label generation handles multiple instances
The system SHALL generate unique labels for each tool instance.
#### Scenario: Unique router names
- **WHEN** multiple instances of the same tool exist
- **THEN** each instance gets a unique router name
- **AND** no label collisions occur
@@ -1,44 +0,0 @@
## 1. Traefik Label Generator
- [x] 1.1 Create apps/api/app/services/traefik.py with TraefikLabelGenerator class
- [x] 1.2 Implement subdomain generation from tool_id, project_slug, user_slug
- [x] 1.3 Generate router labels (rule, service, tls)
- [x] 1.4 Generate service labels (loadBalancer, port)
- [x] 1.5 Add middleware labels for security headers
- [x] 1.6 Write unit tests for label generation
## 2. Backend Integration
- [x] 2.1 Integrate label generation into tool instance spawn endpoint
- [x] 2.2 Store generated labels in tool_instance metadata
- [x] 2.3 Remove/disable labels on tool instance stop
- [x] 2.4 Update ToolInstance model to store labels JSON
## 3. Production Docker Compose
- [x] 3.1 Create docker-compose.prod.yml with api, web, traefik, db services
- [x] 3.2 Configure Traefik service with Let's Encrypt certificates
- [x] 3.3 Set up platform and tools networks
- [x] 3.4 Add health checks for all services
- [x] 3.5 Configure logging (JSON format, rotation)
## 4. Portainer Deployment
- [x] 4.1 Create deploy/portainer-stack.yml
- [x] 4.2 Add Portainer-specific environment variable documentation
- [x] 4.3 Create deploy/.env.example for production
- [x] 4.4 Test stack deployment locally with docker compose -f docker-compose.prod.yml
## 5. Documentation
- [x] 5.1 Update docs/deployment.md with production deployment steps
- [x] 5.2 Add Traefik configuration guide
- [x] 5.3 Document subdomain scheme and DNS requirements
- [x] 5.4 Update README.md with deployment section
## 6. Testing & Verification
- [x] 6.1 Test label generation for all built-in tools
- [x] 6.2 Verify Traefik routes correctly in local stack
- [x] 6.3 Run backend tests: `cd apps/api && pytest`
- [x] 6.4 Run linters: `ruff check app/` and `mypy app/`
@@ -1,2 +1,2 @@
schema: spec-driven
created: 2026-05-14
created: 2026-05-16
@@ -1,2 +1,2 @@
schema: spec-driven
created: 2026-05-14
created: 2026-05-16
@@ -0,0 +1,79 @@
## Context
The platform has a Git abstraction layer in `apps/api/app/git/` with:
- `provider.py`: GitProvider ABC (validate_connection, list_repos, create_deploy_key, delete_deploy_key, get_default_branch)
- `connection.py`: ConnectionManager (connect/disconnect/get_connection)
- `credentials.py`: GitCredential, AccessTokenCredential, CredentialStorage ABC
- `ssh_key.py`: SshKeyPair, SshKeyLifecycle with Ed25519 generation
- `operations.py`: GitOperations ABC, LocalGitOperations (clone/fetch/push are NotImplementedError)
- `types.py`: ProviderKind, CredentialKind, ConnectionStatus, SshKeyStatus enums
Current gaps:
- No concrete provider adapters (GitHub, GitLab)
- CredentialStorage has no database implementation
- LocalGitOperations is incomplete
- No RepositoryConnection router or API endpoints
- SSH key generation uses base64 placeholder
## Goals / Non-Goals
**Goals:**
- Implement concrete GitHub and GitLab provider adapters
- Create database-backed credential storage with encryption
- Complete LocalGitOperations with credential-aware subprocess
- Add RepositoryConnection router with CRUD endpoints
- Generate Ed25519 SSH keys and register deploy keys
- Frontend UI for repository connections and SSH key management
**Non-Goals:**
- Support for Gitea/Forgejo (deferred post-MVP)
- GitHub/GitLab OAuth app integration (use personal access tokens)
- Webhook management
- Repository mirroring
- Branch protection management
## Decisions
**1. Use httpx for provider API calls**
- Rationale: Already in dependencies, async support, consistent with FastAPI
- Alternative: requests - blocking, would need thread pool
**2. Store credentials encrypted with Fernet (same as secrets)**
- Rationale: Consistent with existing secret storage in FN-009
- Implementation: Reuse encryption service from app/encryption.py
**3. SSH keys generated per-repository (not per-user)**
- Rationale: Fine-grained access control, easy revocation per repo
- Alternative: Per-user keys - broader blast radius on compromise
**4. Provider adapters implement GitProvider ABC**
- Rationale: Clean abstraction, easy to add new providers
- Implementation: GitHubAdapter, GitLabAdapter with unified interface
**5. Git operations use subprocess with SSH key in temp file**
- Rationale: Standard git CLI is most reliable
- Implementation: Write key to temp file, set GIT_SSH_COMMAND env var
## Risks / Trade-offs
**[Risk] Personal access tokens have broad permissions**
→ Mitigation: Document minimal required scopes (repo read/write, deploy key management)
**[Risk] SSH keys in temp files are briefly exposed on disk**
→ Mitigation: Use 0600 permissions, clean up immediately after operation
**[Risk] Provider API rate limits**
→ Mitigation: Cache repository lists, implement exponential backoff
**[Risk] Token storage compromise**
→ Mitigation: Fernet encryption with rotation support
## Migration Plan
No migration. New feature.
## Open Questions
1. Should we support SSH key passphrases?
2. Do we need to validate repository URLs before connection?
3. Should we auto-detect provider from URL?
@@ -0,0 +1,39 @@
## Why
The platform needs a provider-independent Git connection model so users can connect repositories from GitHub, GitLab, Gitea, or Forgejo without vendor lock-in. Currently, the Git abstraction layer exists but lacks concrete provider adapters, credential storage, and API endpoints for managing connections. This is the foundation for repository cloning, branch management, and automated deploy key registration.
## What Changes
- **Provider adapter framework**: Concrete implementations for GitHub and GitLab APIs with unified interface
- **Credential storage backend**: Database-backed storage for access tokens and SSH keys with encryption
- **Repository connection API**: REST endpoints for creating, listing, and deleting repository connections
- **SSH key lifecycle**: Ed25519 key generation, public key retrieval, and deploy key registration
- **Git operations**: Complete LocalGitOperations with credential-aware clone, fetch, and push
- **Frontend repository UI**: Interface for connecting repositories and managing SSH keys
## Capabilities
### New Capabilities
- `git-provider-adapter`: Unified interface for Git provider APIs (GitHub, GitLab)
- `credential-storage`: Encrypted storage for access tokens and SSH keys
- `repository-connection`: API for managing repository connections
- `ssh-key-lifecycle`: SSH key generation and deploy key management
- `git-operations`: Credential-aware Git operations (clone, fetch, push)
### Modified Capabilities
- None (extends existing Git abstraction)
## Impact
- **apps/api/app/git/**: New provider adapters and completed operations
- **apps/api/app/routers/repository_connections.py**: New router
- **apps/api/app/models/repository_connection.py**: Enhanced model
- **apps/api/app/schemas/repository_connection.py**: New schemas
- **apps/web/src/pages/RepositoriesPage.tsx**: Enhanced UI
- **apps/web/src/pages/RepositoryConnectionPage.tsx**: New page
## Dependencies
- FN-003: Tool Registry (manifest system)
- FN-004: Backend Foundation (models, auth)
- FN-009: Config & Secrets (encryption, credential storage)
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Credentials are stored encrypted
The system SHALL store access tokens and SSH keys encrypted at rest.
#### Scenario: Store access token
- **WHEN** a user saves an access token
- **THEN** the system encrypts it with Fernet
- **AND** stores the encrypted value in the database
#### Scenario: Retrieve access token
- **WHEN** the system retrieves a credential for API calls
- **THEN** it decrypts the value
- **AND** returns the plaintext token
#### Scenario: List credentials without exposing values
- **WHEN** a user lists their credentials
- **THEN** the system returns metadata (name, provider, created_at)
- **AND** masks the token value (showing only last 4 characters)
### Requirement: Credential storage supports multiple providers
The system SHALL support storing credentials for different Git providers.
#### Scenario: Store GitHub token
- **WHEN** a user adds a GitHub personal access token
- **THEN** the system stores it with provider_type="github"
#### Scenario: Store GitLab token
- **WHEN** a user adds a GitLab personal access token
- **THEN** the system stores it with provider_type="gitlab"
@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Git clone uses SSH credentials
The system SHALL clone repositories using SSH keys.
#### Scenario: Clone repository
- **WHEN** the system clones a repository
- **THEN** it writes the SSH private key to a temporary file
- **AND** sets GIT_SSH_COMMAND to use the key
- **AND** executes git clone
- **AND** cleans up the temporary key file
#### Scenario: Clone fails with invalid key
- **WHEN** a clone operation fails due to authentication
- **THEN** the system returns a clear error message
- **AND** suggests checking deploy key permissions
### Requirement: Git fetch and push use credentials
The system SHALL support fetch and push operations with SSH credentials.
#### Scenario: Fetch updates
- **WHEN** the system fetches from a remote
- **THEN** it uses the stored SSH key for authentication
- **AND** returns the fetch result
#### Scenario: Push changes
- **WHEN** the system pushes to a remote
- **THEN** it uses the stored SSH key for authentication
- **AND** returns the push result
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: GitHub adapter implements provider interface
The system SHALL provide a GitHub adapter that implements the GitProvider interface.
#### Scenario: List repositories
- **WHEN** the adapter lists repositories for an authenticated user
- **THEN** it returns a list of repository objects with name, url, and default_branch
#### Scenario: Create deploy key
- **WHEN** the adapter creates a deploy key for a repository
- **THEN** it registers the SSH public key with GitHub
- **AND** returns the key ID
#### Scenario: Validate connection
- **WHEN** the adapter validates a token
- **THEN** it verifies the token with GitHub API
- **AND** returns user information
### Requirement: GitLab adapter implements provider interface
The system SHALL provide a GitLab adapter that implements the GitProvider interface.
#### Scenario: List repositories
- **WHEN** the adapter lists repositories for an authenticated user
- **THEN** it returns a list of repository objects with name, url, and default_branch
#### Scenario: Create deploy key
- **WHEN** the adapter creates a deploy key for a repository
- **THEN** it registers the SSH public key with GitLab
- **AND** returns the key ID
#### Scenario: Validate connection
- **WHEN** the adapter validates a token
- **THEN** it verifies the token with GitLab API
- **AND** returns user information
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Repository connections can be created
The system SHALL allow users to create connections to Git repositories.
#### Scenario: Connect GitHub repository
- **WHEN** a user provides a GitHub repository URL and access token
- **THEN** the system validates the URL and token
- **AND** creates a RepositoryConnection record
- **AND** generates an SSH key pair
- **AND** registers the deploy key with GitHub
#### Scenario: Connect GitLab repository
- **WHEN** a user provides a GitLab repository URL and access token
- **THEN** the system validates the URL and token
- **AND** creates a RepositoryConnection record
- **AND** generates an SSH key pair
- **AND** registers the deploy key with GitLab
#### Scenario: Reject invalid URL
- **WHEN** a user provides an invalid repository URL
- **THEN** the system returns a 400 error with validation message
### Requirement: Repository connections can be listed and retrieved
The system SHALL allow users to list and view their repository connections.
#### Scenario: List connections
- **WHEN** a user requests their repository connections
- **THEN** the system returns a list with status and metadata
#### Scenario: Get connection details
- **WHEN** a user requests a specific connection
- **THEN** the system returns full details including SSH public key
### Requirement: Repository connections can be deleted
The system SHALL allow users to delete repository connections.
#### Scenario: Delete connection
- **WHEN** a user deletes a connection
- **THEN** the system removes the deploy key from the provider
- **AND** deletes the SSH key pair
- **AND** marks the connection as deleted
@@ -0,0 +1,28 @@
## ADDED Requirements
### Requirement: SSH keys are generated per repository
The system SHALL generate Ed25519 SSH key pairs for each repository connection.
#### Scenario: Generate key pair
- **WHEN** a repository connection is created
- **THEN** the system generates an Ed25519 key pair
- **AND** stores the private key encrypted
- **AND** returns the public key for deploy key registration
#### Scenario: Retrieve public key
- **WHEN** a user requests the public key for a connection
- **THEN** the system returns the SSH public key string
### Requirement: SSH keys support lifecycle operations
The system SHALL support rotating and revoking SSH keys.
#### Scenario: Rotate key
- **WHEN** a user rotates an SSH key
- **THEN** the system generates a new key pair
- **AND** updates the deploy key on the provider
- **AND** deletes the old key pair
#### Scenario: Revoke key
- **WHEN** a connection is deleted
- **THEN** the system deletes the deploy key from the provider
- **AND** securely deletes the local key pair
@@ -0,0 +1,63 @@
## 1. Provider Adapters
- [ ] 1.1 Implement GitHubAdapter in apps/api/app/git/providers/github.py
- [ ] 1.2 Implement GitLabAdapter in apps/api/app/git/providers/gitlab.py
- [ ] 1.3 Add provider factory in apps/api/app/git/providers/__init__.py
- [ ] 1.4 Write tests for GitHubAdapter (mock API responses)
- [ ] 1.5 Write tests for GitLabAdapter (mock API responses)
## 2. Credential Storage
- [ ] 2.1 Create CredentialStorage implementation in apps/api/app/git/credentials.py
- [ ] 2.2 Add database model for GitCredential if needed
- [ ] 2.3 Integrate Fernet encryption from app/encryption.py
- [ ] 2.4 Add credential router in apps/api/app/routers/credentials.py
- [ ] 2.5 Write tests for credential storage
## 3. SSH Key Lifecycle
- [ ] 3.1 Complete SshKeyLifecycle.generate_key_pair() with real Ed25519
- [ ] 3.2 Add SSH key endpoints in apps/api/app/routers/ssh_keys.py
- [ ] 3.3 Implement key rotation logic
- [ ] 3.4 Write tests for SSH key generation
## 4. Repository Connection API
- [ ] 4.1 Create RepositoryConnection router in apps/api/app/routers/repository_connections.py
- [ ] 4.2 Implement POST /api/v1/repository-connections endpoint
- [ ] 4.3 Implement GET /api/v1/repository-connections endpoint
- [ ] 4.4 Implement GET /api/v1/repository-connections/:id endpoint
- [ ] 4.5 Implement DELETE /api/v1/repository-connections/:id endpoint
- [ ] 4.6 Add validation for repository URLs and tokens
- [ ] 4.7 Write tests for repository connection endpoints
## 5. Git Operations
- [ ] 5.1 Complete LocalGitOperations.clone() with SSH key
- [ ] 5.2 Complete LocalGitOperations.fetch() with SSH key
- [ ] 5.3 Complete LocalGitOperations.push() with SSH key
- [ ] 5.4 Add error handling for auth failures
- [ ] 5.5 Write tests for git operations
## 6. Frontend UI
- [ ] 6.1 Create RepositoryConnectionListPage.tsx
- [ ] 6.2 Create RepositoryConnectionFormPage.tsx
- [ ] 6.3 Add repository connection routes to router.tsx
- [ ] 6.4 Add API client methods for repository connections
- [ ] 6.5 Add types for repository connections
## 7. Documentation
- [ ] 7.1 Update docs/architecture.md with Git connection model
- [ ] 7.2 Update docs/development.md with setup instructions
- [ ] 7.3 Add provider setup guide (GitHub/GitLab tokens)
## 8. Testing & Verification
- [ ] 8.1 Run all backend tests (target: 90+)
- [ ] 8.2 Run ruff linter
- [ ] 8.3 Run mypy type checker
- [ ] 8.4 Run frontend tests
- [ ] 8.5 Verify API endpoints with manual testing
- [ ] 8.6 Update project specsheet
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-14
-65
View File
@@ -1,65 +0,0 @@
## Context
OpenCode is an AI-powered terminal-based development environment with a web interface. Unlike code-server which is a full web IDE, OpenCode provides a terminal experience accessible through the browser. This POC validates that the spawn system handles different runtime types including web terminal forwarding.
Current state:
- OpenCode manifest exists in apps/api/app/tools/manifests/opencode.yml
- No container image or runtime defined yet
- No health reporting mechanism
- Spawn infrastructure will be built in FN-010
## Goals / Non-Goals
**Goals:**
- Define OpenCode as a spawnable tool
- Provide web terminal interface in container
- Report health status (running/idle/error)
- Support interactive terminal sessions
**Non-Goals:**
- Full task queue or job scheduler
- Persistent process management
- Log streaming (deferred)
- Multi-language support beyond terminal
## Decisions
**1. Use official OpenCode Docker image**
- Rationale: Maintained, includes AI features and web terminal
- Alternative: Custom image - unnecessary for POC
**2. OpenCode runs as a persistent container**
- Rationale: Easier to manage lifecycle (start/stop/status). Terminal sessions need persistent container.
- Implementation: Container runs OpenCode with web interface on port 3000
**3. Health check via HTTP endpoint**
- Rationale: Standard Docker health check mechanism. Traefik can use it.
- Endpoint: `GET /` returns 200 when ready
**4. Workspace mounted from host (same as code-server)**
- Rationale: Consistency. Shared workspace between tools.
- Path: `/data/workspaces/{user_slug}/{project_slug}`
**5. Configs/secrets injected same as code-server**
- Rationale: Reuse FN-009 infrastructure. No special handling needed.
## Risks / Trade-offs
**[Risk] OpenCode container requires significant resources**
→ Mitigation: Set resource limits (4GB RAM, 2 CPU). Document requirements.
**[Risk] Web terminal performance over slow connections**
→ Mitigation: Use modern terminal emulation with compression. Document bandwidth requirements.
**[Risk] AI features require API keys**
→ Mitigation: Support secret injection for API keys. Document configuration.
## Migration Plan
No migration. New feature.
## Open Questions
1. Should OpenCode support multiple terminal sessions?
2. Do we pre-configure common development tools?
3. Should OpenCode integrate with the platform's AI provider?
@@ -1,28 +0,0 @@
## Why
OpenCode is an AI-powered terminal-based development environment that provides a web interface for interactive development. It demonstrates the platform's extensibility beyond standard tools like code-server. As a POC, it validates the manifest-driven spawn system with a non-trivial runtime that requires web terminal forwarding.
## What Changes
- **OpenCode manifest**: Define the tool with terminal web interface, workspace mounts, and health checks
- **Container image**: Reference to OpenCode image with built-in web terminal
- **Health reporting**: Endpoint that reports tool health to the platform
- **Spawn integration**: Reuse the spawn flow from FN-010 but with OpenCode-specific configuration
- **Web terminal**: Support for browser-based terminal access
## Capabilities
### New Capabilities
- `opencode-manifest`: OpenCode tool manifest with web terminal config
- `web-terminal`: Support for browser-based terminal interfaces
- `health-reporting`: Tool health status reporting mechanism
### Modified Capabilities
- None (reuses spawn infrastructure from FN-010)
## Impact
- **apps/api/app/tools/manifests/opencode.yml**: Updated manifest
- **apps/api/app/services/spawn.py**: Minor updates for OpenCode-specific mounts
- **apps/web/src/**: OpenCode appears in tool selection UI
- **Docker images**: Uses official OpenCode image
@@ -1,27 +0,0 @@
## ADDED Requirements
### Requirement: Container provides web terminal interface
The system SHALL provide a web terminal interface in the OpenCode container.
#### Scenario: Terminal available
- **WHEN** the OpenCode container is running
- **THEN** a web terminal is accessible via HTTP on port 3000
- **AND** the user can execute shell commands through the browser
#### Scenario: Workspace access
- **WHEN** the container runs
- **THEN** the project workspace is mounted at /workspace
- **AND** the user can read/write files in the workspace
### Requirement: Container supports AI features
The system SHALL allow AI-powered development features in the OpenCode environment.
#### Scenario: AI assistance
- **WHEN** the user interacts with OpenCode
- **THEN** AI features are available for code completion and assistance
- **AND** the user can configure AI provider settings
#### Scenario: Terminal session persistence
- **WHEN** the user opens a terminal session
- **THEN** the session persists while the container runs
- **AND** multiple sessions can be opened
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Tool reports health status
The system SHALL provide a mechanism for OpenCode to report its health.
#### Scenario: Health endpoint
- **WHEN** the OpenCode container is running
- **THEN** it exposes a / endpoint for health checks
- **AND** returns 200 when the web terminal is ready
#### Scenario: Health check in Traefik
- **WHEN** the container is spawned
- **THEN** Traefik uses the health endpoint for routing decisions
- **AND** unhealthy containers are removed from the load balancer
### Requirement: Platform tracks tool health
The system SHALL track and display the health of OpenCode instances.
#### Scenario: Status display
- **WHEN** the user views an OpenCode instance
- **THEN** the current status is displayed (healthy, unhealthy, starting)
- **AND** the status updates automatically
@@ -1,15 +0,0 @@
## ADDED Requirements
### Requirement: OpenCode manifest defines web terminal environment
The system SHALL provide an OpenCode manifest with web terminal configuration.
#### Scenario: Manifest includes terminal config
- **WHEN** the OpenCode manifest is loaded
- **THEN** it specifies an OpenCode Docker image with web interface
- **AND** it defines exposed ports for the HTTP interface (port 3000)
- **AND** it defines volume mounts (workspace, config)
#### Scenario: Manifest includes health check
- **WHEN** the manifest is used for spawning
- **THEN** it defines a health check endpoint
- **AND** specifies health check interval and timeout
-42
View File
@@ -1,42 +0,0 @@
## 1. Manifest Definition
- [x] 1.1 Create apps/api/app/tools/manifests/opencode.yml with web terminal config
- [x] 1.2 Add Docker image (ghcr.io/opencode-ai/opencode:latest), ports (3000), volumes
- [x] 1.3 Add health check configuration to manifest
- [x] 1.4 Validate manifest against ToolManifest schema
## 2. Container Setup
- [x] 2.1 Verify OpenCode image availability and configuration
- [x] 2.2 Document web terminal access pattern
- [x] 2.3 Configure environment variables for terminal support
- [x] 2.4 Test container locally with docker run
- [x] 2.5 Verify web terminal accessibility
## 3. Spawn Integration
- [x] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
- [x] 3.2 Add OpenCode-specific volume mounts (config)
- [x] 3.3 Test spawn via API endpoint
- [x] 3.4 Verify Traefik routing to OpenCode container
## 4. Frontend Integration
- [x] 4.1 Add OpenCode to tool selection dropdown
- [x] 4.2 Display OpenCode-specific options in spawn form
- [x] 4.3 Show OpenCode instance status in detail page
## 5. Testing & Verification
- [x] 5.1 Test terminal availability in spawned container
- [x] 5.2 Test web interface accessibility
- [x] 5.3 Test health endpoint response
- [x] 5.4 Verify workspace mount is accessible
- [x] 5.5 Run full test suite: `make test`
- [x] 5.6 Run linters: `make lint`
## 6. Documentation
- [x] 6.1 Document OpenCode setup in docs/development.md
- [x] 6.2 Add OpenCode usage guide
- [x] 6.3 Document terminal configuration and AI features