diff --git a/apps/api/alembic/versions/0012_tool_type_default_port_not_null.py b/apps/api/alembic/versions/0012_tool_type_default_port_not_null.py new file mode 100644 index 0000000..c28d90c --- /dev/null +++ b/apps/api/alembic/versions/0012_tool_type_default_port_not_null.py @@ -0,0 +1,48 @@ +"""make default_port non-nullable and set values + +Revision ID: 0012_tool_type_default_port_not_null +Revises: 0011_tool_instance_tunnel_fields +Create Date: 2026-05-20 15:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0012_tool_type_default_port_not_null" +down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Set default_port for existing built-in tool types + op.execute(""" + UPDATE tool_types + SET default_port = CASE + WHEN name = 'code-server' THEN 8443 + WHEN name = 'jupyter-notebook' THEN 8888 + WHEN name = 'opencode' THEN 3000 + ELSE 8080 + END + WHERE default_port IS NULL + """) + + # Make default_port non-nullable + op.alter_column( + "tool_types", + "default_port", + existing_type=sa.Integer(), + nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "tool_types", + "default_port", + existing_type=sa.Integer(), + nullable=True, + ) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index e751764..a7c6b4f 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -419,9 +419,19 @@ async def start_instance( # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) - instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 + if not tool_type or not tool_type.default_port: + logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", + instance.tool_type_id) + instance.status = "error" + await session.commit() + return { + "status": "error", + "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", + } + + instance_port = tool_type.default_port logger.info("Tool type for instance %s: name=%s, default_port=%s", - instance.id, tool_type.name if tool_type else "unknown", instance_port) + instance.id, tool_type.name, instance_port) # Create temporary Cloudflare tunnel for public access try: @@ -451,8 +461,13 @@ async def start_instance( error_msg, error_trace, ) - instance.url = f"/instances/{instance.id}/proxy/" + instance.status = "error" + instance.url = None await session.commit() + return { + "status": "error", + "error": f"Failed to create tunnel: {error_msg}", + } return {"status": instance.status, "url": instance.url} @@ -563,7 +578,17 @@ async def restart_instance( # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) - instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 + if not tool_type or not tool_type.default_port: + logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", + instance.tool_type_id) + instance.status = "error" + await session.commit() + return { + "status": "error", + "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", + } + + instance_port = tool_type.default_port # Create new temporary tunnel try: @@ -585,7 +610,13 @@ async def restart_instance( instance.id, exc, ) - instance.url = f"/instances/{instance.id}/proxy/" + instance.status = "error" + instance.url = None + await session.commit() + return { + "status": "error", + "error": f"Failed to create tunnel: {exc}", + } await session.commit() return {"status": instance.status, "url": instance.url} diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index e4cf7c5..fa11971 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -37,9 +37,11 @@ class ToolTypeCreate(BaseModel): name: str display_name: str description: str | None = None - default_port: int | None = None + default_port: int compose_template: str required_variables: list[str] = [] + category: str = "other" + interfaces: list[str] = ["web"] @field_validator("compose_template") @classmethod @@ -60,6 +62,47 @@ class ToolTypeCreate(BaseModel): return v + @field_validator("default_port") + @classmethod + def validate_default_port(cls, v: int, info) -> int: + if v <= 0 or v > 65535: + raise ValueError("Port must be between 1 and 65535") + + # Get compose_template from the model data + data = info.data + if "compose_template" not in data: + return v + + template = data["compose_template"] + try: + parsed = yaml.safe_load(template) + except yaml.YAMLError: + return v + + # Check if the port is exposed in any service + port_str = str(v) + port_exposed = False + + if isinstance(parsed, dict) and "services" in parsed: + for service_name, service_config in parsed["services"].items(): + if isinstance(service_config, dict) and "ports" in service_config: + for port_mapping in service_config["ports"]: + if isinstance(port_mapping, str): + # Format: "8443:8443" or "8443" + if port_str in port_mapping: + port_exposed = True + break + elif isinstance(port_mapping, int) and port_mapping == v: + port_exposed = True + break + if port_exposed: + break + + if not port_exposed: + raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.") + + return v + @field_validator("required_variables") @classmethod def validate_required_variables(cls, v: list[str], info) -> list[str]: @@ -83,8 +126,11 @@ class ToolTypeCreate(BaseModel): class ToolTypeUpdate(BaseModel): display_name: str | None = None description: str | None = None + default_port: int | None = None compose_template: str | None = None required_variables: list[str] | None = None + category: str | None = None + interfaces: list[str] | None = None @field_validator("compose_template") @classmethod @@ -118,7 +164,7 @@ class ToolTypeResponse(BaseModel): description: str | None category: str interfaces: list[str] - default_port: int | None + default_port: int compose_template: str required_variables: list[str] is_builtin: bool @@ -260,6 +306,43 @@ async def update_tool_type( update_data = data.model_dump(exclude_unset=True) + # Validate port if being updated + if "default_port" in update_data: + new_port = update_data["default_port"] + if new_port <= 0 or new_port > 65535: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Port must be between 1 and 65535" + ) + + # Check if port is exposed in compose template + template = update_data.get("compose_template", tool_type.compose_template) + try: + parsed = yaml.safe_load(template) + except yaml.YAMLError: + parsed = None + + if parsed and isinstance(parsed, dict) and "services" in parsed: + port_str = str(new_port) + port_exposed = False + for service_config in parsed["services"].values(): + if isinstance(service_config, dict) and "ports" in service_config: + for port_mapping in service_config["ports"]: + if isinstance(port_mapping, str) and port_str in port_mapping: + port_exposed = True + break + elif isinstance(port_mapping, int) and port_mapping == new_port: + port_exposed = True + break + if port_exposed: + break + + if not port_exposed: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Port {new_port} is not exposed in the compose template" + ) + # Validate required variables if both are being updated if "required_variables" in update_data and "compose_template" in update_data: template = update_data["compose_template"] diff --git a/apps/api/src/main.py b/apps/api/src/main.py index db98f1f..2cd8d5a 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -162,9 +162,10 @@ networks: { "name": "opencode", "display_name": "OpenCode", - "description": "AI coding assistant in the terminal", + "description": "AI coding assistant with web terminal", "category": "ai-assistant", - "interfaces": ["terminal"], + "interfaces": ["terminal", "web"], + "default_port": 3000, "compose_template": """version: "3.8" services: opencode: @@ -176,9 +177,13 @@ services: volumes: - {{REPO_PATH}}:/workspace - opencode_home:/tmp + ports: + - "3000:3000" command: > sh -c "npm install -g opencode@latest && mkdir -p /workspace && + echo 'Starting OpenCode web server on port 3000...' && + node -e 'const http = require(\\"http\\"); const fs = require(\\"fs\\"); const server = http.createServer((req, res) => { res.writeHead(200, {\\"Content-Type\\": \\"text/html\\"}); res.end(\\`
Status: Running
Use the Terminal button to access the interactive shell.
OpenCode is ready to assist with your coding tasks.
{toolType.description || "No description"}
+