feat: add remote URL attachment support and project editing endpoint

This commit is contained in:
2026-06-10 22:17:25 +00:00
parent 9b425b33a5
commit 3540bc8fa6
43 changed files with 1520 additions and 133 deletions
+46 -3
View File
@@ -227,6 +227,25 @@ ACTIONS: tuple[Action, ...] = (
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="edit_project",
method="POST",
path="/projects/edit/{project_slug}",
summary="Edit an existing project",
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
body("title", "Updated project title.", required=True),
body("description", "Updated project description.", required=True),
body("release_date", "Updated release date."),
body("demo_date", "Updated demo date."),
body("project_type", "Updated project type."),
body("platforms", "Updated supported platforms."),
body("status", "Updated project status."),
),
),
Action(
name="delete_project",
method="POST",
@@ -828,6 +847,27 @@ ACTIONS: tuple[Action, ...] = (
summary="Upload a local file and obtain its attachment uid",
params=(upload("file", "Local filesystem path of the file to upload."),),
),
Action(
name="attach_url",
method="POST",
path="/uploads/upload-url",
summary="Download a file from a public URL and store it as an attachment, returning its uid",
description=(
"Fetches the file at the given URL on the server (size-capped, SSRF-guarded) and stores it "
"as an attachment exactly like an upload, returning {uid, url, mime_type, ...}. Pass the "
"returned uid in attachment_uids when you create_post, create_project, create_gist, "
"create_comment, create_bug, or send_message to attach it. The file type is taken from the "
"URL or its Content-Type; supply 'filename' (with an allowed extension) when the URL has no "
"usable name. Use this to attach an image or file straight from the internet."
),
params=(
body("url", "Public http(s) URL of the file to download and attach.", required=True),
body(
"filename",
"Optional filename with an allowed extension, used when the URL has no clear name.",
),
),
),
Action(
name="delete_attachment",
method="DELETE",
@@ -847,10 +887,13 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/analytics",
summary="Site-wide aggregate analytics in one call (admin only)",
description=(
"Returns JSON: total members, active users in the last 24h/7d/30d, users signed in "
"now, new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
"Returns JSON: total members, active users in the last 24h/7d/30d, signed_in_now, "
"new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
"news), and top authors. Use this for any 'how many'/'how active'/count question "
"instead of paging through admin_list_users."
"instead of paging through admin_list_users. signed_in_now counts members holding an "
"unexpired session (logged in within the session lifetime, default 7-30 days), NOT a "
"real-time online count - see signed_in_definition in the response and never report it "
"as 'currently online' or 'logged in right now'."
),
params=(query("top_n", "How many top authors to include (1-50)."),),
requires_admin=True,
@@ -103,11 +103,15 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
path="",
handler="container",
requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output",
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("command", "Command to run, e.g. 'pip list'.", required=True),
arg(
"command",
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
required=True,
),
),
),
Action(
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import re
from typing import Any
from urllib.parse import quote, urlencode
@@ -35,8 +36,20 @@ CONFIRM_REQUIRED = {
"customize_set_css",
"customize_set_js",
"customize_reset",
"project_delete_file",
"delete_project",
}
DESTRUCTIVE_COMMAND = re.compile(
r"(?:^|[\s;&|`(])(?:sudo\s+)?(rm|rmdir|unlink|shred|truncate|wipefs|mkfs\S*|dd)(?:\s|$)"
r"|>\s*/"
r"|\bdrop\s+(?:table|database)\b"
r"|\bdelete\s+from\b"
r"|\bgit\s+clean\b"
r"|\s-delete\b",
re.IGNORECASE,
)
logger = logging.getLogger("devii.dispatch")
@@ -49,8 +62,12 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
)
def _is_destructive_command(arguments: dict[str, Any]) -> bool:
return bool(DESTRUCTIVE_COMMAND.search(str(arguments.get("command", ""))))
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
if _is_confirmed(arguments):
return None
if name in ("customize_set_css", "customize_set_js"):
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
@@ -64,11 +81,39 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
"edits. Ask the user to confirm this explicitly first, then call again with "
"confirm=true."
)
if name == "project_set_readonly":
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
"edits. Ask the user to confirm this explicitly first, then call again with "
"confirm=true."
)
if name == "project_delete_file":
path = str(arguments.get("path", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting '{path}' is permanent and cannot be undone. Show the user the exact path, "
"get explicit confirmation, then call again with confirm=true."
)
if name == "delete_project":
return ToolInputError(
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
"to confirm this explicitly first, then call again with confirm=true."
)
if (
name == "container_instance_action"
and str(arguments.get("action", "")).strip().lower() == "delete"
):
return ToolInputError(
"Deleting a container instance is irreversible and destroys its state. Ask the user to "
"confirm explicitly, then call again with confirm=true."
)
if name == "container_exec" and _is_destructive_command(arguments):
command = str(arguments.get("command", "")).strip()
return ToolInputError(
"This command can permanently delete files or data (it contains a destructive operation "
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
"explicit confirmation, then call again with confirm=true."
)
return None
class Dispatcher:
@@ -86,6 +131,7 @@ class Dispatcher:
owner_kind: str = "guest",
owner_id: str = "",
virtual_tools: Any = None,
behavior: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
@@ -107,6 +153,7 @@ class Dispatcher:
self._customization = CustomizationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@staticmethod
@@ -226,6 +273,15 @@ class Dispatcher:
if action.handler == "customization":
return await self._customization.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
ToolInputError(
"Behavior configuration is not available in this context."
)
)
return await self._behavior.dispatch(action.name, arguments)
if action.handler == "virtual_tool":
if self._virtual_tools is None:
return error_result(
@@ -34,4 +34,64 @@ FETCH_ACTIONS: tuple[Action, ...] = (
),
),
),
Action(
name="http_request",
method="LOCAL",
path="",
summary="Make an arbitrary HTTP request (GET/POST/PUT/PATCH/DELETE) to any external API",
description=(
"The general HTTP client for external services and REST/JSON APIs. Send any method with "
"custom headers and a request body, and read the full response (status, headers, content). "
"Use the 'json' argument to send a JSON body (Content-Type is set automatically), 'form' to "
"send url-encoded form fields, or 'body' to send a raw string. The response body is returned "
"verbatim for JSON, or as readable text for HTML, capped to fit the context window; non-2xx "
"responses are returned (not raised) so you can inspect API errors. Private and loopback "
"addresses are refused. Call this DIRECTLY to talk to an API; never wrap it in a user-defined "
"tool or eval that calls itself. Prefer fetch_url for simply reading a web page."
),
handler="fetch",
requires_auth=True,
params=(
Param(
name="url",
location="body",
description="The request URL (https is assumed if no scheme is given).",
required=True,
),
Param(
name="method",
location="body",
description="HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS. Defaults to GET.",
),
Param(
name="headers",
location="body",
description="Optional request headers as a JSON object of string key/value pairs.",
type="object",
),
Param(
name="json",
location="body",
description="Optional JSON request body as an object or array; sets Content-Type to application/json.",
type="object",
),
Param(
name="form",
location="body",
description="Optional url-encoded form body as a JSON object of string key/value pairs.",
type="object",
),
Param(
name="body",
location="body",
description="Optional raw string request body; set a matching Content-Type header yourself.",
),
Param(
name="max_chars",
location="body",
description="Optional cap on returned response characters; clamped to a context-safe maximum.",
type="integer",
),
),
),
)
@@ -61,6 +61,12 @@ class Action:
"items": {"type": "object"},
"description": param.description,
}
elif param.type == "object":
properties[param.name] = {
"type": "object",
"description": param.description,
"additionalProperties": True,
}
else:
properties[param.name] = {
"type": param.type,