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:
@@ -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,
|
||||||
|
)
|
||||||
@@ -419,9 +419,19 @@ async def start_instance(
|
|||||||
|
|
||||||
# Get tool type for default port
|
# Get tool type for default port
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
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",
|
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
|
# Create temporary Cloudflare tunnel for public access
|
||||||
try:
|
try:
|
||||||
@@ -451,8 +461,13 @@ async def start_instance(
|
|||||||
error_msg,
|
error_msg,
|
||||||
error_trace,
|
error_trace,
|
||||||
)
|
)
|
||||||
instance.url = f"/instances/{instance.id}/proxy/"
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to create tunnel: {error_msg}",
|
||||||
|
}
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
@@ -563,7 +578,17 @@ async def restart_instance(
|
|||||||
|
|
||||||
# Get tool type for default port
|
# Get tool type for default port
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
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
|
# Create new temporary tunnel
|
||||||
try:
|
try:
|
||||||
@@ -585,7 +610,13 @@ async def restart_instance(
|
|||||||
instance.id,
|
instance.id,
|
||||||
exc,
|
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()
|
await session.commit()
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int | None = None
|
default_port: int
|
||||||
compose_template: str
|
compose_template: str
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interfaces: list[str] = ["web"]
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -60,6 +62,47 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
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")
|
@field_validator("required_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
@@ -83,8 +126,11 @@ class ToolTypeCreate(BaseModel):
|
|||||||
class ToolTypeUpdate(BaseModel):
|
class ToolTypeUpdate(BaseModel):
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interfaces: list[str] | None = None
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -118,7 +164,7 @@ class ToolTypeResponse(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
category: str
|
category: str
|
||||||
interfaces: list[str]
|
interfaces: list[str]
|
||||||
default_port: int | None
|
default_port: int
|
||||||
compose_template: str
|
compose_template: str
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
@@ -260,6 +306,43 @@ async def update_tool_type(
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
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
|
# Validate required variables if both are being updated
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
template = update_data["compose_template"]
|
||||||
|
|||||||
@@ -162,9 +162,10 @@ networks:
|
|||||||
{
|
{
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"display_name": "OpenCode",
|
"display_name": "OpenCode",
|
||||||
"description": "AI coding assistant in the terminal",
|
"description": "AI coding assistant with web terminal",
|
||||||
"category": "ai-assistant",
|
"category": "ai-assistant",
|
||||||
"interfaces": ["terminal"],
|
"interfaces": ["terminal", "web"],
|
||||||
|
"default_port": 3000,
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
opencode:
|
opencode:
|
||||||
@@ -176,9 +177,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/workspace
|
- {{REPO_PATH}}:/workspace
|
||||||
- opencode_home:/tmp
|
- opencode_home:/tmp
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
command: >
|
command: >
|
||||||
sh -c "npm install -g opencode@latest &&
|
sh -c "npm install -g opencode@latest &&
|
||||||
mkdir -p /workspace &&
|
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"
|
tail -f /dev/null"
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
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)
|
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, 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)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export interface CreateToolTypeRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port: number;
|
||||||
compose_template: string;
|
compose_template: string;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,9 @@ export interface CreateToolTypeRequest {
|
|||||||
export interface UpdateToolTypeRequest {
|
export interface UpdateToolTypeRequest {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port?: number;
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
required_variables?: string[];
|
required_variables?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const ToolTypesPage = () => {
|
|||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDisplayName, setFormDisplayName] = useState("");
|
const [formDisplayName, setFormDisplayName] = useState("");
|
||||||
const [formDescription, setFormDescription] = useState("");
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formCategory, setFormCategory] = useState("");
|
||||||
|
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
||||||
|
const [formPort, setFormPort] = useState("");
|
||||||
const [formTemplate, setFormTemplate] = useState("");
|
const [formTemplate, setFormTemplate] = useState("");
|
||||||
const [formVariables, setFormVariables] = useState("");
|
const [formVariables, setFormVariables] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -47,6 +50,9 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName("");
|
setFormName("");
|
||||||
setFormDisplayName("");
|
setFormDisplayName("");
|
||||||
setFormDescription("");
|
setFormDescription("");
|
||||||
|
setFormCategory("");
|
||||||
|
setFormInterfaces([]);
|
||||||
|
setFormPort("");
|
||||||
setFormTemplate("");
|
setFormTemplate("");
|
||||||
setFormVariables("");
|
setFormVariables("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -58,6 +64,9 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName(toolType.name);
|
setFormName(toolType.name);
|
||||||
setFormDisplayName(toolType.display_name);
|
setFormDisplayName(toolType.display_name);
|
||||||
setFormDescription(toolType.description ?? "");
|
setFormDescription(toolType.description ?? "");
|
||||||
|
setFormCategory(toolType.category ?? "");
|
||||||
|
setFormInterfaces(toolType.interfaces ?? []);
|
||||||
|
setFormPort(toolType.default_port?.toString() ?? "");
|
||||||
setFormTemplate(toolType.compose_template);
|
setFormTemplate(toolType.compose_template);
|
||||||
setFormVariables(toolType.required_variables.join(", "));
|
setFormVariables(toolType.required_variables.join(", "));
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -80,6 +89,11 @@ export const ToolTypesPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||||
|
setFormError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const variables = formVariables
|
const variables = formVariables
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
@@ -91,6 +105,9 @@ export const ToolTypesPage = () => {
|
|||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -99,6 +116,9 @@ export const ToolTypesPage = () => {
|
|||||||
const input: UpdateToolTypeRequest = {
|
const input: UpdateToolTypeRequest = {
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -164,6 +184,13 @@ export const ToolTypesPage = () => {
|
|||||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
<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">
|
<div className="card-actions">
|
||||||
{!toolType.is_builtin && (
|
{!toolType.is_builtin && (
|
||||||
<>
|
<>
|
||||||
@@ -240,6 +267,61 @@ export const ToolTypesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label>Compose Template (YAML)</label>
|
<label>Compose Template (YAML)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-20
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool types have inconsistent port configuration:
|
||||||
|
- `code-server`: default_port=8443, interfaces=["web"]
|
||||||
|
- `jupyter-notebook`: default_port=8888, interfaces=["web"]
|
||||||
|
- `opencode`: default_port=undefined, interfaces=["terminal"]
|
||||||
|
|
||||||
|
The tunnel creation code falls back to port 8080 when no default_port is set, which causes 502 Bad Gateway errors since OpenCode doesn't listen on any port.
|
||||||
|
|
||||||
|
OpenCode currently runs `tail -f /dev/null` in its container, keeping it alive for terminal access via WebSocket but providing no web interface. The user wants OpenCode accessible via a web terminal in the browser.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Make `default_port` a required field for all tool types with validation
|
||||||
|
- Add a web server to OpenCode so it exposes a port for browser access
|
||||||
|
- Ensure tunnel creation always uses the correct port from tool type config
|
||||||
|
- Support tools with both terminal and web interfaces
|
||||||
|
- Add compose template validation to ensure defined ports are actually exposed
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Changing the existing WebSocket terminal implementation
|
||||||
|
- Adding new authentication or authorization
|
||||||
|
- Supporting non-HTTP protocols for tunnels
|
||||||
|
- Modifying code-server or jupyter configurations
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: OpenCode exposes a web terminal on port 3000
|
||||||
|
|
||||||
|
**Rationale:** OpenCode needs a web interface for browser access. We'll run a lightweight web server (using `npx serve` or a simple Node.js HTTP server) alongside the OpenCode CLI.
|
||||||
|
|
||||||
|
**Alternative considered:** Use a separate web terminal service (like ttyd or wetty). Rejected because it adds complexity and another dependency.
|
||||||
|
|
||||||
|
### Decision: Tools can have multiple interfaces
|
||||||
|
|
||||||
|
**Rationale:** OpenCode should support both terminal (via WebSocket) and web (via browser) access. The `interfaces` field should allow `["terminal", "web"]`.
|
||||||
|
|
||||||
|
### Decision: Validate ports in compose templates
|
||||||
|
|
||||||
|
**Rationale:** Prevent misconfiguration where a tool type claims to use port 8443 but the compose template doesn't expose it.
|
||||||
|
|
||||||
|
**Implementation:** When creating/updating tool types, parse the compose template YAML and verify the port is in the `ports` section.
|
||||||
|
|
||||||
|
### Decision: Store tunnel URL in instance.url, not public_url
|
||||||
|
|
||||||
|
**Rationale:** Simplify the data model. The `url` field is what the frontend uses to open tools. `public_url` is redundant.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk]** OpenCode web terminal may not work well without proper TTY support
|
||||||
|
→ **Mitigation**: Test thoroughly, fall back to raw terminal if needed
|
||||||
|
|
||||||
|
- **[Risk]** Running a web server in OpenCode container increases resource usage
|
||||||
|
→ **Mitigation**: Use a minimal static file server (~5MB memory)
|
||||||
|
|
||||||
|
- **[Risk]** Port conflicts if multiple instances use the same default_port
|
||||||
|
→ **Mitigation**: Docker maps container ports to host ports automatically, internal ports can overlap
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Update OpenCode compose template to include a web server
|
||||||
|
2. Add `default_port: 3000` to OpenCode seed data
|
||||||
|
3. Add port validation to tool type API
|
||||||
|
4. Update instance list to show both Open and Terminal buttons for dual-interface tools
|
||||||
|
5. Test OpenCode instance creation and tunnel access
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should we use `npx serve` or a custom Node.js server for OpenCode web UI?
|
||||||
|
- Should the web terminal use the existing xterm.js component or redirect to a separate page?
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Tool instances currently have inconsistent port configuration. OpenCode lacks a default port and doesn't expose a web interface, while code-server and jupyter have hardcoded ports. We need a systematic way to define tool ports and ensure OpenCode works properly via the web terminal interface.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Tool Port Configuration**: Make `default_port` required for all tool types and validate it during tool type creation
|
||||||
|
- **OpenCode Web Terminal**: Configure OpenCode to run a web server (e.g., on port 3000) so it can be accessed via browser, not just through the raw WebSocket terminal
|
||||||
|
- **Tunnel Port Discovery**: Ensure cloudflared tunnels use the correct internal port from the tool type definition
|
||||||
|
- **Terminal-First Tools**: Add support for tools that primarily use the terminal interface but may also expose a web UI
|
||||||
|
- **Tool Validation**: Add validation to ensure tool compose templates expose the port defined in `default_port`
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `tool-port-configuration`: Systematic port definition and validation for tool types
|
||||||
|
- `opencode-web-server`: Running OpenCode with a web interface accessible via browser
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `tool-types`: Adding port validation requirements and web interface support for terminal tools
|
||||||
|
- `tool-instances`: Tunnel creation must read port from tool type configuration
|
||||||
|
- `tool-terminal`: Terminal tools may optionally expose web endpoints
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: Tool type model, validation, seed data, tunnel creation logic
|
||||||
|
- Frontend: Instance list may show both Open (web) and Terminal buttons for tools with dual interfaces
|
||||||
|
- Docker: OpenCode compose template needs a web server command
|
||||||
|
- Infrastructure: Cloudflared tunnels must target the correct internal port
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: OpenCode runs a web server
|
||||||
|
|
||||||
|
The system SHALL configure OpenCode containers to run a web server accessible on port 3000.
|
||||||
|
|
||||||
|
#### Scenario: OpenCode container starts
|
||||||
|
- **GIVEN** an OpenCode tool instance
|
||||||
|
- **WHEN** the container starts
|
||||||
|
- **THEN** a web server is running on port 3000 inside the container
|
||||||
|
- **AND** the server serves a web terminal interface
|
||||||
|
|
||||||
|
### Requirement: OpenCode exposes web interface
|
||||||
|
|
||||||
|
The system SHALL mark OpenCode as having both terminal and web interfaces.
|
||||||
|
|
||||||
|
#### Scenario: OpenCode instance created
|
||||||
|
- **GIVEN** a new OpenCode instance
|
||||||
|
- **WHEN** the instance list is displayed
|
||||||
|
- **THEN** both "Open" and "Terminal" buttons are shown
|
||||||
|
|
||||||
|
### Requirement: OpenCode web terminal uses correct port
|
||||||
|
|
||||||
|
The system SHALL use port 3000 when creating tunnels for OpenCode instances.
|
||||||
|
|
||||||
|
#### Scenario: Tunnel created for OpenCode
|
||||||
|
- **GIVEN** an OpenCode instance with `default_port: 3000`
|
||||||
|
- **WHEN** the instance starts and creates a tunnel
|
||||||
|
- **THEN** the tunnel targets `http://container-name:3000`
|
||||||
|
|
||||||
|
### Requirement: OpenCode web terminal displays properly
|
||||||
|
|
||||||
|
The system SHALL serve a functional web terminal interface for OpenCode.
|
||||||
|
|
||||||
|
#### Scenario: User opens OpenCode web UI
|
||||||
|
- **GIVEN** a running OpenCode instance
|
||||||
|
- **WHEN** the user clicks the "Open" button
|
||||||
|
- **THEN** a new tab opens with the OpenCode web interface
|
||||||
|
- **AND** the interface shows a terminal connected to the OpenCode process
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool types must define a default port
|
||||||
|
|
||||||
|
The system SHALL require all tool types to specify a `default_port`.
|
||||||
|
|
||||||
|
#### Scenario: Creating tool type without port
|
||||||
|
- **GIVEN** a user creating a new tool type
|
||||||
|
- **WHEN** they omit the `default_port` field
|
||||||
|
- **THEN** the system rejects the request with a 422 error
|
||||||
|
|
||||||
|
#### Scenario: Creating tool type with port
|
||||||
|
- **GIVEN** a user creating a new tool type with `default_port: 3000`
|
||||||
|
- **WHEN** the request is submitted
|
||||||
|
- **THEN** the tool type is created successfully
|
||||||
|
|
||||||
|
### Requirement: Tool type port must be exposed in compose template
|
||||||
|
|
||||||
|
The system SHALL validate that the compose template exposes the port defined in `default_port`.
|
||||||
|
|
||||||
|
#### Scenario: Port mismatch
|
||||||
|
- **GIVEN** a tool type with `default_port: 8443`
|
||||||
|
- **WHEN** the compose template only exposes port `3000`
|
||||||
|
- **THEN** the system rejects with an error indicating the port mismatch
|
||||||
|
|
||||||
|
#### Scenario: Port exposed correctly
|
||||||
|
- **GIVEN** a tool type with `default_port: 8443`
|
||||||
|
- **WHEN** the compose template exposes port `8443` via `ports: ["8443:8443"]`
|
||||||
|
- **THEN** the tool type is accepted
|
||||||
|
|
||||||
|
### Requirement: Tool types support multiple interfaces
|
||||||
|
|
||||||
|
The system SHALL allow tool types to specify multiple interfaces.
|
||||||
|
|
||||||
|
#### Scenario: Tool with web and terminal interfaces
|
||||||
|
- **GIVEN** a tool type with `interfaces: ["terminal", "web"]`
|
||||||
|
- **WHEN** an instance is created
|
||||||
|
- **THEN** the instance shows both "Open" (web) and "Terminal" buttons in the UI
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tool Type Model
|
||||||
|
|
||||||
|
The system SHALL store tool type definitions in the database.
|
||||||
|
|
||||||
|
#### Scenario: Create tool type
|
||||||
|
- GIVEN an admin user
|
||||||
|
- WHEN they define a new tool type
|
||||||
|
- THEN the following fields are stored:
|
||||||
|
- name: Tool identifier
|
||||||
|
- description: Human-readable description
|
||||||
|
- docker_compose_template: Compose file template
|
||||||
|
- icon: Visual identifier
|
||||||
|
- category: Tool category
|
||||||
|
- default_env_vars: Default environment variables
|
||||||
|
- default_port: **Required** primary port the tool listens on
|
||||||
|
- interfaces: List of supported interfaces ("web", "terminal")
|
||||||
|
|
||||||
|
#### Scenario: Tool type without port rejected
|
||||||
|
- GIVEN a user creating a tool type without `default_port`
|
||||||
|
- WHEN the request is submitted
|
||||||
|
- THEN the system rejects with a 422 validation error
|
||||||
|
|
||||||
|
### Requirement: Built-in Tools
|
||||||
|
|
||||||
|
The system SHALL include default tool types.
|
||||||
|
|
||||||
|
#### Scenario: Built-in tools
|
||||||
|
- GIVEN a fresh installation
|
||||||
|
- THEN these tool types are pre-configured:
|
||||||
|
- code-server: VS Code in browser (port 8443, interfaces: ["web"])
|
||||||
|
- jupyter-notebook: Jupyter notebooks (port 8888, interfaces: ["web"])
|
||||||
|
- opencode: OpenCode agent environment (port 3000, interfaces: ["terminal", "web"])
|
||||||
|
|
||||||
|
### Requirement: Template Validation
|
||||||
|
|
||||||
|
The system SHALL validate Docker Compose templates.
|
||||||
|
|
||||||
|
#### Scenario: Invalid template
|
||||||
|
- GIVEN an invalid Docker Compose template
|
||||||
|
- WHEN a user tries to create/update a tool type
|
||||||
|
- THEN the system rejects with validation errors
|
||||||
|
|
||||||
|
#### Scenario: Port not exposed in template
|
||||||
|
- GIVEN a tool type with `default_port: 8443`
|
||||||
|
- WHEN the compose template does not expose port 8443
|
||||||
|
- THEN the system rejects with a validation error indicating the port mismatch
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## 1. Tool Type Port Configuration
|
||||||
|
|
||||||
|
- [x] 1.1 Update ToolType model to make `default_port` required (non-nullable)
|
||||||
|
- [x] 1.2 Add validation in tool type API to reject missing `default_port`
|
||||||
|
- [x] 1.3 Add compose template validation to verify port is exposed in `ports` section
|
||||||
|
- [x] 1.4 Update tool type creation/update endpoints to validate port configuration
|
||||||
|
|
||||||
|
## 2. OpenCode Web Server
|
||||||
|
|
||||||
|
- [x] 2.1 Update OpenCode compose template to run a web server on port 3000
|
||||||
|
- [x] 2.2 Add `default_port: 3000` to OpenCode seed data
|
||||||
|
- [x] 2.3 Update OpenCode `interfaces` to `["terminal", "web"]`
|
||||||
|
- [x] 2.4 Create a simple web terminal HTML page served by OpenCode container
|
||||||
|
|
||||||
|
## 3. Tunnel Port Fix
|
||||||
|
|
||||||
|
- [x] 3.1 Update tunnel creation to use `tool_type.default_port` instead of hardcoded 8080
|
||||||
|
- [x] 3.2 Ensure tunnel creation fails gracefully if port is not defined
|
||||||
|
- [x] 3.3 Remove fallback to port 8080 in tunnel creation
|
||||||
|
|
||||||
|
## 4. Frontend Updates
|
||||||
|
|
||||||
|
- [x] 4.1 Update instance list to show both "Open" and "Terminal" buttons for dual-interface tools
|
||||||
|
- [x] 4.2 Update ToolType interface in frontend to include `default_port`
|
||||||
|
- [x] 4.3 Update tool type creation form to require port input
|
||||||
|
|
||||||
|
## 5. Database Migration
|
||||||
|
|
||||||
|
- [x] 5.1 Create Alembic migration to make `default_port` non-nullable
|
||||||
|
- [x] 5.2 Set `default_port` for existing tool types (code-server=8443, jupyter=8888, opencode=3000)
|
||||||
|
|
||||||
|
## 6. Testing & Quality Gates
|
||||||
|
|
||||||
|
- [ ] 6.1 Test creating tool type without port fails validation
|
||||||
|
- [ ] 6.2 Test creating tool type with port mismatch fails validation
|
||||||
|
- [ ] 6.3 Test OpenCode instance creates tunnel on port 3000
|
||||||
|
- [ ] 6.4 Run backend quality gates (ruff, mypy)
|
||||||
|
- [ ] 6.5 Run frontend quality gates (typecheck, lint, build)
|
||||||
|
- [ ] 6.6 Commit and push changes
|
||||||
Reference in New Issue
Block a user