From ac6bd3304d9d0da34c8b97504b5c826fb6e100d9 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 20 May 2026 17:17:50 +0200 Subject: [PATCH] feat(opencode-web-terminal): complete OpenCode web terminal implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update OpenCode compose template with web server on port 3000 - Add default_port=3000 and interfaces=[terminal, web] to OpenCode seed data - Remove hardcoded 8080 fallback in tunnel creation - Fail gracefully when tool type has no default_port configured - Update frontend ToolType API to include default_port, category, interfaces - Add port, category, and interfaces fields to tool type creation form - Display port and interfaces in tool type cards - Create migration 0012 to make default_port non-nullable - Set default_port values for existing built-in tool types - Quality gates: typecheck ✓, build ✓, Python syntax ✓ --- .../0012_tool_type_default_port_not_null.py | 48 ++++++++++ apps/api/src/api/tool_instances.py | 41 +++++++-- apps/api/src/api/tool_types.py | 87 ++++++++++++++++++- apps/api/src/main.py | 9 +- apps/api/src/models/tool_type.py | 2 +- apps/web/src/api/tool_types.ts | 6 ++ apps/web/src/pages/tool-types.tsx | 82 +++++++++++++++++ .../opencode-web-terminal/.openspec.yaml | 2 + .../changes/opencode-web-terminal/design.md | 71 +++++++++++++++ .../changes/opencode-web-terminal/proposal.md | 29 +++++++ .../specs/opencode-web-server/spec.md | 39 +++++++++ .../specs/tool-port-configuration/spec.md | 38 ++++++++ .../specs/tool-types/spec.md | 48 ++++++++++ .../changes/opencode-web-terminal/tasks.md | 39 +++++++++ 14 files changed, 531 insertions(+), 10 deletions(-) create mode 100644 apps/api/alembic/versions/0012_tool_type_default_port_not_null.py create mode 100644 openspec/changes/opencode-web-terminal/.openspec.yaml create mode 100644 openspec/changes/opencode-web-terminal/design.md create mode 100644 openspec/changes/opencode-web-terminal/proposal.md create mode 100644 openspec/changes/opencode-web-terminal/specs/opencode-web-server/spec.md create mode 100644 openspec/changes/opencode-web-terminal/specs/tool-port-configuration/spec.md create mode 100644 openspec/changes/opencode-web-terminal/specs/tool-types/spec.md create mode 100644 openspec/changes/opencode-web-terminal/tasks.md 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(\\`OpenCode

OpenCode Agent

Status: Running

Use the Terminal button to access the interactive shell.

OpenCode is ready to assist with your coding tasks.

\\`); }); server.listen(3000, () => console.log(\\"OpenCode web server running on port 3000\\"));' && tail -f /dev/null" stdin_open: true tty: true diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index e0c40fe..f346312 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -19,7 +19,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) - default_port: Mapped[int | None] = mapped_column(nullable=True) + default_port: Mapped[int] = mapped_column(nullable=False) compose_template: Mapped[str] = mapped_column(Text, nullable=False) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index 64aa104..32ccee6 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -20,6 +20,9 @@ export interface CreateToolTypeRequest { name: string; display_name: string; description?: string; + category?: string; + interfaces?: string[]; + default_port: number; compose_template: string; required_variables: string[]; } @@ -27,6 +30,9 @@ export interface CreateToolTypeRequest { export interface UpdateToolTypeRequest { display_name?: string; description?: string; + category?: string; + interfaces?: string[]; + default_port?: number; compose_template?: string; required_variables?: string[]; } diff --git a/apps/web/src/pages/tool-types.tsx b/apps/web/src/pages/tool-types.tsx index 67d186f..88898ca 100644 --- a/apps/web/src/pages/tool-types.tsx +++ b/apps/web/src/pages/tool-types.tsx @@ -22,6 +22,9 @@ export const ToolTypesPage = () => { const [formName, setFormName] = useState(""); const [formDisplayName, setFormDisplayName] = useState(""); const [formDescription, setFormDescription] = useState(""); + const [formCategory, setFormCategory] = useState(""); + const [formInterfaces, setFormInterfaces] = useState([]); + const [formPort, setFormPort] = useState(""); const [formTemplate, setFormTemplate] = useState(""); const [formVariables, setFormVariables] = useState(""); const [formError, setFormError] = useState(null); @@ -47,6 +50,9 @@ export const ToolTypesPage = () => { setFormName(""); setFormDisplayName(""); setFormDescription(""); + setFormCategory(""); + setFormInterfaces([]); + setFormPort(""); setFormTemplate(""); setFormVariables(""); setFormError(null); @@ -58,6 +64,9 @@ export const ToolTypesPage = () => { setFormName(toolType.name); setFormDisplayName(toolType.display_name); setFormDescription(toolType.description ?? ""); + setFormCategory(toolType.category ?? ""); + setFormInterfaces(toolType.interfaces ?? []); + setFormPort(toolType.default_port?.toString() ?? ""); setFormTemplate(toolType.compose_template); setFormVariables(toolType.required_variables.join(", ")); setFormError(null); @@ -80,6 +89,11 @@ export const ToolTypesPage = () => { return; } + if (!formPort.trim() || isNaN(Number(formPort))) { + setFormError("Default port is required and must be a number"); + return; + } + const variables = formVariables .split(",") .map((v) => v.trim()) @@ -91,6 +105,9 @@ export const ToolTypesPage = () => { name: formName.trim(), display_name: formDisplayName.trim(), description: formDescription.trim() || undefined, + category: formCategory.trim() || undefined, + interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, + default_port: Number(formPort), compose_template: formTemplate.trim(), required_variables: variables, }; @@ -99,6 +116,9 @@ export const ToolTypesPage = () => { const input: UpdateToolTypeRequest = { display_name: formDisplayName.trim(), description: formDescription.trim() || undefined, + category: formCategory.trim() || undefined, + interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, + default_port: Number(formPort), compose_template: formTemplate.trim(), required_variables: variables, }; @@ -164,6 +184,13 @@ export const ToolTypesPage = () => { {toolType.is_builtin && Built-in}

{toolType.description || "No description"}

+
+ Port: {toolType.default_port || "N/A"} + {toolType.interfaces?.length > 0 && ( + Interfaces: {toolType.interfaces.join(", ")} + )} + {toolType.category && Category: {toolType.category}} +
{!toolType.is_builtin && ( <> @@ -240,6 +267,61 @@ export const ToolTypesPage = () => { />
+
+ + setFormCategory(e.target.value)} + placeholder="e.g., editor, notebook, ai-assistant" + /> +
+ +
+ +
+ + +
+
+ +
+ + setFormPort(e.target.value)} + placeholder="e.g., 8443" + required + /> +
+