feat(opencode-web-terminal): complete OpenCode web terminal implementation

- 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 ✓
This commit is contained in:
Fusion
2026-05-20 17:17:50 +02:00
parent 6ec35988cc
commit ac6bd3304d
14 changed files with 531 additions and 10 deletions
@@ -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,
)
+36 -5
View File
@@ -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}
+85 -2
View File
@@ -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"]
+7 -2
View File
@@ -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(\\`<!DOCTYPE html><html><head><title>OpenCode</title><style>body{font-family:monospace;background:#1e1e1e;color:#d4d4d4;margin:0;padding:20px;}h1{color:#4ec9b0;}.container{max-width:800px;margin:0 auto;}.status{padding:10px;background:#2d2d2d;border-radius:4px;margin:20px 0;}</style></head><body><div class=\\"container\\"><h1>OpenCode Agent</h1><div class=\\"status\\"><p>Status: Running</p><p>Use the Terminal button to access the interactive shell.</p></div><p>OpenCode is ready to assist with your coding tasks.</p></div></body></html>\\`); }); server.listen(3000, () => console.log(\\"OpenCode web server running on port 3000\\"));' &&
tail -f /dev/null"
stdin_open: true
tty: true
+1 -1
View File
@@ -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)
+6
View File
@@ -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[];
}
+82
View File
@@ -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<string[]>([]);
const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(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 && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
@@ -240,6 +267,61 @@ export const ToolTypesPage = () => {
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
}
}}
/>
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
}
}}
/>
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea