Compare commits

..

2 Commits

Author SHA1 Message Date
rob 3f259449d4 Add tool composition UI with auto-dependency management
- Add ToolStepDialog for configuring tool steps (tool selection, input mapping, args)
- Add "Add Tool" button in Tool Builder alongside Add Prompt/Add Code
- Add ToolNode in flow graph visualization (purple puzzle piece icon)
- Auto-populate dependencies when adding ToolStep in GUI
- Add --auto-install flag for automatic runtime dependency installation
- Add check_dependencies() and auto_install_dependencies() functions
- Update runner to pass auto_install parameter through execution chain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 05:27:54 -04:00
rob 05a2fae94c Add password reset flow with email-based token verification
Implement secure password reset functionality:
- Add password_reset_tokens table for storing hashed reset tokens
- Create email utility module (dev mode logs to console)
- Add API endpoints: request, validate, and complete password reset
- Add web routes and templates for forgot-password and reset-password
- Security: 1-hour token expiry, single-use, rate limiting, session invalidation
- Prevent email enumeration by always returning success message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 04:34:43 -04:00
12 changed files with 1012 additions and 18 deletions

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
) )
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
from ...providers import load_providers, call_provider from ...providers import load_providers, call_provider
from ...profiles import list_profiles from ...profiles import list_profiles
@ -424,3 +424,218 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
output_var=self.output_input.text().strip(), output_var=self.output_input.text().strip(),
name=name name=name
) )
class ToolStepDialog(QDialog):
"""Dialog for adding/editing tool steps (calling another tool)."""
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
super().__init__(parent)
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
self.setMinimumSize(550, 500)
self._step = step
self._available_vars = available_vars or ["input"]
self._current_tool_name = current_tool_name # To prevent self-referencing
self._tool_args = {} # Cache of tool arguments
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Tool selection
self.tool_combo = QComboBox()
self._populate_tools()
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
form.addRow("Tool:", self.tool_combo)
# Tool description (read-only)
self.tool_desc = QLabel("")
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
self.tool_desc.setWordWrap(True)
form.addRow("", self.tool_desc)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("tool_output")
self.output_input.setText("tool_output")
form.addRow("Output variable:", self.output_input)
# Provider override (optional)
self.provider_combo = QComboBox()
self.provider_combo.addItem("(use tool's default)")
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
form.addRow("Provider override:", self.provider_combo)
layout.addLayout(form)
# Input template
input_group = QGroupBox("Input")
input_layout = QVBoxLayout(input_group)
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
input_help = QLabel(f"Available variables: {vars_text}")
input_help.setStyleSheet("color: #718096; font-size: 11px;")
input_layout.addWidget(input_help)
self.input_template = QPlainTextEdit()
self.input_template.setPlaceholderText("{input}")
self.input_template.setPlainText("{input}")
self.input_template.setMaximumHeight(80)
input_layout.addWidget(self.input_template)
layout.addWidget(input_group)
# Tool arguments
self.args_group = QGroupBox("Tool Arguments")
self.args_layout = QFormLayout(self.args_group)
self.args_layout.setSpacing(8)
self._arg_inputs = {} # variable -> QLineEdit
layout.addWidget(self.args_group)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
# Trigger initial tool selection
if self.tool_combo.count() > 0:
self._on_tool_changed(self.tool_combo.currentText())
def _populate_tools(self):
"""Populate the tool dropdown."""
tools = list_tools()
for tool_name in sorted(tools):
# Skip the current tool to prevent self-referencing
if tool_name != self._current_tool_name:
self.tool_combo.addItem(tool_name)
def _on_tool_changed(self, tool_name: str):
"""Handle tool selection change."""
# Clear existing arg inputs
for widget in self._arg_inputs.values():
self.args_layout.removeRow(widget)
self._arg_inputs.clear()
if not tool_name:
self.tool_desc.setText("")
return
tool = load_tool(tool_name)
if not tool:
self.tool_desc.setText("(Tool not found)")
return
# Update description
self.tool_desc.setText(tool.description or "(No description)")
# Cache arguments
self._tool_args[tool_name] = tool.arguments
# Create input fields for each argument
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
for arg in tool.arguments:
line = QLineEdit()
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
if arg.default:
line.setText(arg.default)
self._arg_inputs[arg.variable] = line
label = f"{arg.flag}:"
if arg.description:
label = f"{arg.flag} ({arg.description}):"
self.args_layout.addRow(label, line)
def _load_step(self, step: ToolStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
# Select tool
idx = self.tool_combo.findText(step.tool)
if idx >= 0:
self.tool_combo.setCurrentIndex(idx)
else:
# Tool might be qualified name - try to add it
self.tool_combo.addItem(step.tool)
self.tool_combo.setCurrentText(step.tool)
self.output_input.setText(step.output_var)
self.input_template.setPlainText(step.input_template)
# Set provider override
if step.provider:
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
# Set argument values (after tool is selected and args are loaded)
for var, value in step.args.items():
if var in self._arg_inputs:
self._arg_inputs[var].setText(str(value))
def _validate_and_accept(self):
"""Validate and accept."""
tool = self.tool_combo.currentText().strip()
if not tool:
self.tool_combo.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> ToolStep:
"""Get the step from form data."""
# Collect arguments
args = {}
for var, line in self._arg_inputs.items():
value = line.text().strip()
if value:
args[var] = value
# Get provider override
provider = self.provider_combo.currentText()
if provider == "(use tool's default)":
provider = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return ToolStep(
tool=self.tool_combo.currentText(),
output_var=self.output_input.text().strip(),
input_template=self.input_template.toPlainText() or "{input}",
args=args,
provider=provider,
name=name
)

View File

@ -10,10 +10,10 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from ...tool import ( from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
) )
from ..widgets.icons import get_prompt_icon, get_code_icon from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
class ToolBuilderPage(QWidget): class ToolBuilderPage(QWidget):
@ -190,6 +190,10 @@ class ToolBuilderPage(QWidget):
self.btn_add_code.clicked.connect(self._add_code_step) self.btn_add_code.clicked.connect(self._add_code_step)
steps_btns.addWidget(self.btn_add_code) steps_btns.addWidget(self.btn_add_code)
self.btn_add_tool = QPushButton("Add Tool")
self.btn_add_tool.clicked.connect(self._add_tool_step)
steps_btns.addWidget(self.btn_add_tool)
self.btn_edit_step = QPushButton("Edit") self.btn_edit_step = QPushButton("Edit")
self.btn_edit_step.setObjectName("secondary") self.btn_edit_step.setObjectName("secondary")
self.btn_edit_step.clicked.connect(self._edit_step) self.btn_edit_step.clicked.connect(self._edit_step)
@ -379,6 +383,9 @@ class ToolBuilderPage(QWidget):
if arg.variable: if arg.variable:
available_vars.add(arg.variable) available_vars.add(arg.variable)
# Count for default naming
tool_count = 0
for i, step in enumerate(self._tool.steps, 1): for i, step in enumerate(self._tool.steps, 1):
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
prompt_count += 1 prompt_count += 1
@ -391,6 +398,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}" step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}" text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20) icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else: else:
text = f"Unknown step" text = f"Unknown step"
icon = None icon = None
@ -430,6 +442,7 @@ class ToolBuilderPage(QWidget):
# Count steps by type for default naming # Count steps by type for default naming
prompt_count = 0 prompt_count = 0
code_count = 0 code_count = 0
tool_count = 0
# Track available variables for dependency checking # Track available variables for dependency checking
available_vars = {"input"} available_vars = {"input"}
@ -449,6 +462,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}" step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}" text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20) icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else: else:
text = f"Unknown step" text = f"Unknown step"
icon = None icon = None
@ -508,6 +526,23 @@ class ToolBuilderPage(QWidget):
del self._tool.arguments[idx] del self._tool.arguments[idx]
self._refresh_arguments() self._refresh_arguments()
def _add_tool_dependency(self, tool_ref: str):
"""Add a tool reference to the dependencies list if not already present.
Args:
tool_ref: Tool reference (e.g., 'my-tool' or 'owner/tool-name')
"""
if not self._tool or not tool_ref:
return
# Initialize dependencies list if needed
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
self._tool.dependencies = []
# Add if not already present
if tool_ref not in self._tool.dependencies:
self._tool.dependencies.append(tool_ref)
def _get_available_vars(self, up_to_step: int = -1) -> list: def _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step. """Get available variables for a step.
@ -559,6 +594,21 @@ class ToolBuilderPage(QWidget):
self._tool.steps.append(step) self._tool.steps.append(step)
self._refresh_steps() self._refresh_steps()
def _add_tool_step(self):
"""Add a tool step (call another tool)."""
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars()
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, available_vars=available_vars, current_tool_name=current_name)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
# Auto-add to dependencies if not already present
self._add_tool_dependency(step.tool)
self._refresh_steps()
def _edit_step(self): def _edit_step(self):
"""Edit selected step from list view.""" """Edit selected step from list view."""
items = self.steps_list.selectedItems() items = self.steps_list.selectedItems()
@ -578,11 +628,20 @@ class ToolBuilderPage(QWidget):
from ..dialogs.step_dialog import CodeStepDialog from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars(up_to_step=idx) available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars) dialog = CodeStepDialog(self, step, available_vars=available_vars)
elif isinstance(step, ToolStep):
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, step, available_vars=available_vars, current_tool_name=current_name)
else: else:
return return
if dialog.exec(): if dialog.exec():
self._tool.steps[idx] = dialog.get_step() new_step = dialog.get_step()
self._tool.steps[idx] = new_step
# Auto-add to dependencies if it's a ToolStep
if isinstance(new_step, ToolStep):
self._add_tool_dependency(new_step.tool)
self._refresh_steps() self._refresh_steps()
def _delete_step(self): def _delete_step(self):

View File

@ -8,10 +8,10 @@ from PySide6.QtGui import QKeyEvent, QAction
from NodeGraphQt import NodeGraph, BaseNode from NodeGraphQt import NodeGraph, BaseNode
from ...tool import Tool, PromptStep, CodeStep from ...tool import Tool, PromptStep, CodeStep, ToolStep
from .icons import ( from .icons import (
get_prompt_icon_path, get_code_icon_path, get_prompt_icon_path, get_code_icon_path,
get_input_icon_path, get_output_icon_path get_input_icon_path, get_output_icon_path, get_tool_icon_path
) )
@ -105,6 +105,33 @@ class CodeNode(CmdForgeBaseNode):
self.output_ports()[0]._name = step.output_var or 'result' self.output_ports()[0]._name = step.output_var or 'result'
class ToolNode(CmdForgeBaseNode):
"""Node representing a tool step (calling another tool)."""
NODE_NAME = 'Tool'
def __init__(self):
super().__init__()
self.set_color(159, 122, 234) # Purple
self.set_icon(get_tool_icon_path(16))
self.add_input('in', color=(220, 180, 250))
self.add_output('out', color=(250, 180, 220))
# Add properties
self.add_text_input('tool_ref', 'Tool', text='')
self.add_text_input('output_var', 'Output', text='tool_output')
def set_step(self, step: ToolStep, index: int):
"""Configure node from a ToolStep."""
self._step_data = step
self._step_index = index
self.set_property('tool_ref', step.tool or '')
self.set_property('output_var', step.output_var or 'tool_output')
# Update output port name
if self.output_ports():
self.output_ports()[0]._name = step.output_var or 'tool_output'
class OutputNode(CmdForgeBaseNode): class OutputNode(CmdForgeBaseNode):
"""Node representing tool output.""" """Node representing tool output."""
@ -168,6 +195,7 @@ class FlowGraphWidget(QWidget):
self._graph.register_node(InputNode) self._graph.register_node(InputNode)
self._graph.register_node(PromptNode) self._graph.register_node(PromptNode)
self._graph.register_node(CodeNode) self._graph.register_node(CodeNode)
self._graph.register_node(ToolNode)
self._graph.register_node(OutputNode) self._graph.register_node(OutputNode)
# Connect signals # Connect signals
@ -265,6 +293,7 @@ class FlowGraphWidget(QWidget):
# Count steps by type for default naming # Count steps by type for default naming
prompt_count = 0 prompt_count = 0
code_count = 0 code_count = 0
tool_count = 0
for i, step in enumerate(self._tool.steps or []): for i, step in enumerate(self._tool.steps or []):
if isinstance(step, PromptStep): if isinstance(step, PromptStep):
@ -287,6 +316,16 @@ class FlowGraphWidget(QWidget):
pos=[x_pos, 0] pos=[x_pos, 0]
) )
node.set_step(step, i) node.set_step(step, i)
elif isinstance(step, ToolStep):
tool_count += 1
# Use custom name if set, otherwise default to "Tool N" (per type)
node_name = step.name if step.name else f'Tool {tool_count}'
node = self._graph.create_node(
'cmdforge.ToolNode',
name=node_name,
pos=[x_pos, 0]
)
node.set_step(step, i)
else: else:
continue continue
@ -443,7 +482,12 @@ class FlowGraphWidget(QWidget):
def _on_node_double_clicked(self, node): def _on_node_double_clicked(self, node):
"""Handle node double-click.""" """Handle node double-click."""
if hasattr(node, '_step_index') and node._step_index >= 0: if hasattr(node, '_step_index') and node._step_index >= 0:
step_type = 'prompt' if isinstance(node, PromptNode) else 'code' if isinstance(node, PromptNode):
step_type = 'prompt'
elif isinstance(node, ToolNode):
step_type = 'tool'
else:
step_type = 'code'
self.node_double_clicked.emit(node._step_index, step_type) self.node_double_clicked.emit(node._step_index, step_type)
def _on_nodes_deleted(self, nodes): def _on_nodes_deleted(self, nodes):

View File

@ -273,3 +273,63 @@ def get_output_icon(size: int = 24) -> QIcon:
if key not in _icon_cache: if key not in _icon_cache:
_icon_cache[key] = create_output_icon(size) _icon_cache[key] = create_output_icon(size)
return _icon_cache[key] return _icon_cache[key]
def create_tool_icon(size: int = 24, color: QColor = None) -> QIcon:
"""Create a puzzle piece icon for tool steps (calling another tool).
Args:
size: Icon size in pixels
color: Icon color (defaults to purple #9f7aea)
"""
if color is None:
color = QColor(159, 122, 234) # Purple
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
pen = QPen(color, 1.5)
painter.setPen(pen)
painter.setBrush(QBrush(color))
# Draw a simple puzzle piece shape
margin = size * 0.12
w = size - 2 * margin
h = size - 2 * margin
path = QPainterPath()
# Start from top-left, going clockwise
path.moveTo(margin, margin + h * 0.3)
# Top edge with bump
path.lineTo(margin + w * 0.35, margin + h * 0.3)
path.arcTo(QRectF(margin + w * 0.35 - w * 0.1, margin, w * 0.3, h * 0.3), 180, -180)
path.lineTo(margin + w, margin + h * 0.3)
# Right edge
path.lineTo(margin + w, margin + h * 0.7)
# Bottom edge with notch
path.lineTo(margin + w * 0.65, margin + h * 0.7)
path.arcTo(QRectF(margin + w * 0.35, margin + h * 0.7, w * 0.3, h * 0.3), 0, 180)
path.lineTo(margin, margin + h * 0.7)
# Left edge back to start
path.closeSubpath()
painter.drawPath(path)
painter.end()
return QIcon(pixmap)
def get_tool_icon(size: int = 24) -> QIcon:
"""Get cached tool icon."""
key = ('tool', size)
if key not in _icon_cache:
_icon_cache[key] = create_tool_icon(size)
return _icon_cache[key]
def get_tool_icon_path(size: int = 16) -> str:
"""Get file path to tool icon (for NodeGraphQt)."""
return _get_icon_path('tool', size, create_tool_icon)

View File

@ -1741,6 +1741,177 @@ def create_app() -> Flask:
} }
}) })
@app.route("/api/v1/password-reset/request", methods=["POST"])
def request_password_reset() -> Response:
"""Request a password reset email.
Always returns success to prevent email enumeration.
"""
if request.content_length and request.content_length > MAX_BODY_BYTES:
return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413)
# Rate limit by IP
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown")
rate_key = f"{ip}:password_reset"
allowed, _ = rate_limiter.check(rate_key, 5, 3600) # 5 requests per hour per IP
if not allowed:
return error_response("RATE_LIMITED", "Too many password reset requests. Try again later.", 429)
payload = request.get_json(silent=True) or {}
email = (payload.get("email") or "").strip().lower()
# Always return success message (prevent email enumeration)
success_response = jsonify({
"data": {
"status": "success",
"message": "If an account with that email exists, a password reset link has been sent.",
}
})
if not email or not EMAIL_RE.match(email):
return success_response
publisher = query_one(g.db, "SELECT id, email FROM publishers WHERE email = ?", [email])
if not publisher:
return success_response
# Rate limit by email as well
email_rate_key = f"email:{email}:password_reset"
email_allowed, _ = rate_limiter.check(email_rate_key, 3, 3600) # 3 requests per hour per email
if not email_allowed:
return success_response # Still return success to prevent enumeration
# Invalidate any existing unused tokens for this publisher
g.db.execute(
"UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE publisher_id = ? AND used_at IS NULL",
[publisher["id"]],
)
# Generate a secure token
token, token_hash = generate_token()
expires_at = datetime.utcnow() + timedelta(hours=1)
g.db.execute(
"""
INSERT INTO password_reset_tokens (publisher_id, token_hash, expires_at)
VALUES (?, ?, ?)
""",
[publisher["id"], token_hash, expires_at.isoformat()],
)
g.db.commit()
# Send email (logs to console in dev mode)
from cmdforge.web.email import send_password_reset_email
base_url = request.url_root.rstrip("/")
send_password_reset_email(publisher["email"], token, base_url)
return success_response
@app.route("/api/v1/password-reset/validate", methods=["POST"])
def validate_reset_token() -> Response:
"""Validate a password reset token (optional, for UX)."""
payload = request.get_json(silent=True) or {}
token = payload.get("token", "")
if not token:
return error_response("VALIDATION_ERROR", "Token is required", 400)
token_hash = hashlib.sha256(token.encode()).hexdigest()
row = query_one(
g.db,
"""
SELECT id, expires_at, used_at
FROM password_reset_tokens
WHERE token_hash = ?
""",
[token_hash],
)
if not row:
return error_response("INVALID_TOKEN", "Invalid or expired reset token", 400)
if row["used_at"]:
return error_response("TOKEN_USED", "This reset token has already been used", 400)
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)
return jsonify({"data": {"valid": True}})
@app.route("/api/v1/password-reset/complete", methods=["POST"])
def complete_password_reset() -> Response:
"""Complete password reset with token and new password."""
if request.content_length and request.content_length > MAX_BODY_BYTES:
return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413)
payload = request.get_json(silent=True) or {}
token = payload.get("token", "")
new_password = payload.get("new_password", "")
if not token:
return error_response("VALIDATION_ERROR", "Token is required", 400)
if not new_password or len(new_password) < 8:
return error_response("VALIDATION_ERROR", "Password must be at least 8 characters", 400)
token_hash = hashlib.sha256(token.encode()).hexdigest()
row = query_one(
g.db,
"""
SELECT prt.id, prt.publisher_id, prt.expires_at, prt.used_at, p.email
FROM password_reset_tokens prt
JOIN publishers p ON prt.publisher_id = p.id
WHERE prt.token_hash = ?
""",
[token_hash],
)
if not row:
return error_response("INVALID_TOKEN", "Invalid or expired reset token", 400)
if row["used_at"]:
return error_response("TOKEN_USED", "This reset token has already been used", 400)
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)
# Hash and save new password
new_hash = password_hasher.hash(new_password)
# Update password
g.db.execute(
"UPDATE publishers SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[new_hash, row["publisher_id"]],
)
# Mark token as used
g.db.execute(
"UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
[row["id"]],
)
# Invalidate all existing sessions/tokens for this user
g.db.execute(
"UPDATE api_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE publisher_id = ? AND revoked_at IS NULL",
[row["publisher_id"]],
)
g.db.commit()
return jsonify({
"data": {
"status": "success",
"message": "Password has been reset successfully. Please log in with your new password.",
}
})
@app.route("/api/v1/tokens", methods=["POST"]) @app.route("/api/v1/tokens", methods=["POST"])
@require_token @require_token
def create_token() -> Response: def create_token() -> Response:

View File

@ -399,6 +399,19 @@ CREATE TABLE IF NOT EXISTS registry_settings (
); );
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category); CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
-- Password Reset Tokens
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_reset_tokens_publisher ON password_reset_tokens(publisher_id);
""" """

View File

@ -7,7 +7,7 @@ from typing import Optional
from .tool import Tool, PromptStep, CodeStep, ToolStep from .tool import Tool, PromptStep, CodeStep, ToolStep
from .providers import call_provider, mock_provider from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest from .manifest import load_manifest
from .profiles import load_profile from .profiles import load_profile
@ -15,6 +15,36 @@ from .profiles import load_profile
MAX_TOOL_DEPTH = 10 MAX_TOOL_DEPTH = 10
def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]:
"""
Automatically install missing dependencies for a tool.
Args:
tool: Tool to check and install dependencies for
verbose: Show installation progress
Returns:
List of successfully installed tool references
"""
missing = check_dependencies(tool)
if not missing:
return []
installed = []
for dep in missing:
try:
if verbose:
print(f"[auto-install] Installing {dep}...", file=sys.stderr)
install_from_registry(dep)
installed.append(dep)
if verbose:
print(f"[auto-install] Installed {dep}", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr)
return installed
def check_dependencies(tool: Tool, checked: set = None) -> list[str]: def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
""" """
Check if all dependencies for a tool are available. Check if all dependencies for a tool are available.
@ -293,6 +323,7 @@ def run_tool(
dry_run: bool = False, dry_run: bool = False,
show_prompt: bool = False, show_prompt: bool = False,
verbose: bool = False, verbose: bool = False,
auto_install: bool = False,
_depth: int = 0, _depth: int = 0,
_call_stack: Optional[list] = None _call_stack: Optional[list] = None
) -> tuple[str, int]: ) -> tuple[str, int]:
@ -307,6 +338,7 @@ def run_tool(
dry_run: Just show what would happen dry_run: Just show what would happen
show_prompt: Show prompts in addition to output show_prompt: Show prompts in addition to output
verbose: Show debug info verbose: Show debug info
auto_install: Automatically install missing dependencies
_depth: Internal recursion depth tracker _depth: Internal recursion depth tracker
_call_stack: Internal call stack for error reporting _call_stack: Internal call stack for error reporting
@ -322,8 +354,19 @@ def run_tool(
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)): if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
missing = check_dependencies(tool) missing = check_dependencies(tool)
if missing: if missing:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr) if auto_install:
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr) # Try to auto-install missing dependencies
installed = auto_install_dependencies(tool, verbose=verbose)
# Re-check after installation
still_missing = check_dependencies(tool)
if still_missing:
print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr)
elif installed:
print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr)
else:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
print(f"Or use --auto-install to install automatically", file=sys.stderr)
# Continue anyway - the actual step execution will fail with a better error # Continue anyway - the actual step execution will fail with a better error
# Initialize variables with input and arguments # Initialize variables with input and arguments
@ -448,6 +491,8 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
help="Override provider (e.g., --provider mock)") help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true", parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information") help="Show debug information")
parser.add_argument("--auto-install", action="store_true",
help="Automatically install missing tool dependencies")
# Tool-specific flags from arguments # Tool-specific flags from arguments
for arg in tool.arguments: for arg in tool.arguments:
@ -527,7 +572,8 @@ def main():
provider_override=effective_provider, provider_override=effective_provider,
dry_run=args.dry_run, dry_run=args.dry_run,
show_prompt=args.show_prompt, show_prompt=args.show_prompt,
verbose=args.verbose verbose=args.verbose,
auto_install=args.auto_install
) )
# Write output # Write output

View File

@ -101,3 +101,116 @@ def logout():
return redirect(url_for("web.login")) return redirect(url_for("web.login"))
session.clear() session.clear()
return redirect(url_for("web.login")) return redirect(url_for("web.login"))
@web_bp.route("/forgot-password", methods=["GET", "POST"], endpoint="forgot_password")
def forgot_password():
success_message = None
errors = []
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/forgot_password.html",
errors=["Invalid CSRF token"],
)
email = request.form.get("email", "").strip()
result = _api_post("/api/v1/password-reset/request", {"email": email})
if result["status"] == 429:
errors.append("Too many password reset requests. Please try again later.")
else:
# Always show success message to prevent email enumeration
success_message = "If an account with that email exists, a password reset link has been sent. Please check your email."
return render_template(
"pages/forgot_password.html",
success_message=success_message,
errors=errors,
email=email if errors else "",
)
return render_template("pages/forgot_password.html")
@web_bp.route("/reset-password", methods=["GET", "POST"], endpoint="reset_password")
def reset_password():
token = request.args.get("token") or request.form.get("token")
errors = []
success_message = None
if not token:
return render_template(
"pages/reset_password.html",
errors=["Invalid password reset link. Please request a new one."],
token_valid=False,
)
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/reset_password.html",
errors=["Invalid CSRF token"],
token=token,
token_valid=True,
)
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not new_password or len(new_password) < 8:
errors.append("Password must be at least 8 characters.")
elif new_password != confirm_password:
errors.append("Passwords do not match.")
else:
result = _api_post("/api/v1/password-reset/complete", {
"token": token,
"new_password": new_password,
})
if result["status"] == 200:
success_message = "Your password has been reset successfully."
return render_template(
"pages/reset_password.html",
success_message=success_message,
token_valid=False,
)
else:
error = result["data"].get("error", {})
error_code = error.get("code", "")
if error_code == "TOKEN_EXPIRED":
errors.append("This password reset link has expired. Please request a new one.")
elif error_code == "TOKEN_USED":
errors.append("This password reset link has already been used. Please request a new one.")
else:
errors.append(error.get("message", "Failed to reset password."))
return render_template(
"pages/reset_password.html",
errors=errors,
token=token,
token_valid=True,
)
# Validate token on GET
result = _api_post("/api/v1/password-reset/validate", {"token": token})
if result["status"] != 200:
error = result["data"].get("error", {})
error_code = error.get("code", "")
if error_code == "TOKEN_EXPIRED":
errors.append("This password reset link has expired. Please request a new one.")
elif error_code == "TOKEN_USED":
errors.append("This password reset link has already been used. Please request a new one.")
else:
errors.append("Invalid password reset link. Please request a new one.")
return render_template(
"pages/reset_password.html",
errors=errors,
token_valid=False,
)
return render_template(
"pages/reset_password.html",
token=token,
token_valid=True,
)

134
src/cmdforge/web/email.py Normal file
View File

@ -0,0 +1,134 @@
"""Email sending utilities for CmdForge web.
In development mode, emails are logged to the console instead of being sent.
To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings.
"""
from __future__ import annotations
import logging
from typing import Optional
from flask import current_app
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, html_body: str, text_body: Optional[str] = None) -> bool:
"""Send an email.
In dev mode (MAIL_ENABLED=false or unset), logs the email to console.
In production (MAIL_ENABLED=true), sends via SMTP.
Args:
to: Recipient email address
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
Returns:
True if email was sent/logged successfully, False otherwise
"""
mail_enabled = current_app.config.get("MAIL_ENABLED", False)
if mail_enabled:
# Future: implement real SMTP sending
# smtp_host = current_app.config.get("MAIL_SERVER", "localhost")
# smtp_port = current_app.config.get("MAIL_PORT", 587)
# smtp_user = current_app.config.get("MAIL_USERNAME")
# smtp_pass = current_app.config.get("MAIL_PASSWORD")
# smtp_tls = current_app.config.get("MAIL_USE_TLS", True)
logger.warning("MAIL_ENABLED is true but SMTP is not implemented yet. Falling back to console logging.")
# Dev mode: log to console
logger.info("=" * 60)
logger.info("[EMAIL] To: %s", to)
logger.info("[EMAIL] Subject: %s", subject)
logger.info("[EMAIL] Body:")
if text_body:
logger.info(text_body)
else:
logger.info(html_body)
logger.info("=" * 60)
return True
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
"""Send a password reset email.
Args:
to: Recipient email address
token: The password reset token
base_url: Base URL of the application (e.g., https://cmdforge.brrd.tech)
Returns:
True if email was sent/logged successfully
"""
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
subject = "Reset your CmdForge password"
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ text-align: center; margin-bottom: 30px; }}
.logo {{ font-size: 24px; font-weight: bold; color: #4F46E5; }}
.button {{ display: inline-block; padding: 12px 24px; background-color: #4F46E5; color: white; text-decoration: none; border-radius: 6px; font-weight: 500; }}
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }}
.link {{ word-break: break-all; color: #4F46E5; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">CmdForge</div>
</div>
<p>Hello,</p>
<p>You requested to reset your password for your CmdForge account. Click the button below to set a new password:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" class="button">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p class="link">{reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<div class="footer">
<p>This email was sent by CmdForge. If you have questions, visit our website or contact support.</p>
</div>
</div>
</body>
</html>
"""
text_body = f"""Reset your CmdForge password
Hello,
You requested to reset your password for your CmdForge account.
Reset your password by visiting this link:
{reset_url}
This link will expire in 1 hour.
If you didn't request this password reset, you can safely ignore this email.
Your password will remain unchanged.
---
CmdForge
"""
return send_email(to, subject, html_body, text_body)

View File

@ -884,13 +884,6 @@ def terms():
return render_template("pages/terms.html") return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)
@web_bp.route("/robots.txt", endpoint="robots") @web_bp.route("/robots.txt", endpoint="robots")

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Reset Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Reset your password</h1>
<p class="mt-2 text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
</div>
</div>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.forgot_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='you@example.com',
required=true,
value=email or ''
) }}
{{ button_primary('Send reset link', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Set New Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Set new password</h1>
{% if token_valid %}
<p class="mt-2 text-gray-600">
Enter your new password below.
</p>
{% endif %}
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
<p class="mt-2 text-sm text-green-700">
<a href="{{ url_for('web.login') }}" class="font-medium underline hover:text-green-800">
Sign in with your new password
</a>
</p>
</div>
</div>
</div>
{% elif not token_valid %}
{{ form_errors(errors) }}
<div class="text-center">
<p class="text-gray-600 mb-4">Need to reset your password?</p>
<a href="{{ url_for('web.forgot_password') }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Request new reset link
</a>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.reset_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="token" value="{{ token }}">
{{ text_input(
name='new_password',
label='New password',
type='password',
placeholder='At least 8 characters',
required=true,
help='Use a mix of letters, numbers, and symbols'
) }}
{{ text_input(
name='confirm_password',
label='Confirm new password',
type='password',
placeholder='Confirm your new password',
required=true
) }}
{{ button_primary('Set new password', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}