forked from retoor/devplacepy
feat: add remote URL attachment support and project editing endpoint
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user