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
+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)