Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 62 additions & 13 deletions backend/app/core/yaml_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any, Dict, List

import yaml
from packaging.version import InvalidVersion, Version
from sqlalchemy.orm import Session

from app.core.config import settings
Expand All @@ -28,6 +29,35 @@
logger = logging.getLogger(__name__)


def _is_newer_skill_version(existing: Any, candidate_version: Any) -> bool:
"""Return whether a versioned built-in Skill should replace its prior package."""
current_version = str(getattr(existing.spec, "version", "") or "").strip()
next_version = str(candidate_version or "").strip()
if not current_version or not next_version:
return False

try:
return Version(next_version) > Version(current_version)
except InvalidVersion:
logger.warning(
"Skipping built-in Skill version comparison: current=%s candidate=%s",
current_version,
next_version,
)
return False


def _package_skill_directory(skill_folder: Path) -> bytes:
"""Create the ZIP package consumed by the Skill service."""
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for file_path in skill_folder.rglob("*"):
if file_path.is_file():
arcname = f"{skill_folder.name}/{file_path.relative_to(skill_folder)}"
zip_file.write(file_path, arcname)
return zip_buffer.getvalue()


def load_yaml_documents(file_path: Path) -> List[Dict[str, Any]]:
"""
Load YAML documents from a file.
Expand Down Expand Up @@ -390,6 +420,7 @@ def apply_skills_from_directory(
List of operation results
"""
from app.services.adapters.skill_kinds import skill_kinds_service
from app.services.skill_service import SkillValidator

if not skills_dir.exists() or not skills_dir.is_dir():
logger.info(f"Skills directory does not exist: {skills_dir}")
Expand Down Expand Up @@ -417,6 +448,10 @@ def apply_skills_from_directory(
namespace = "default"

try:
zip_content = _package_skill_directory(skill_folder)
zip_filename = f"{skill_name}.zip"
metadata = SkillValidator.validate_zip(zip_content, zip_filename)

# Check if public skill already exists (user_id=0)
existing = skill_kinds_service.get_skill_by_name(
db, name=skill_name, namespace=namespace, user_id=public_user_id
Expand All @@ -433,6 +468,32 @@ def apply_skills_from_directory(
logger.info(
f"Deleted existing public skill for force update: {skill_name}"
)
elif _is_newer_skill_version(existing, metadata.get("version")):
skill_id = int(existing.metadata.labels.get("id"))
skill_kinds_service.update_skill(
db,
skill_id=skill_id,
user_id=public_user_id,
file_content=zip_content,
file_name=zip_filename,
)
Comment on lines +471 to +479

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use all required Kind identifiers during the update.

Lines 473-479 introduce an update path that calls update_skill with only skill_id and user_id. The provided implementation in backend/app/services/adapters/skill_kinds.py:677-778 then queries the Kind without namespace or name.

Extend the update service contract and its Kind query to require and match namespace, name, and user_id. Add coverage that verifies these identity predicates.

As per coding guidelines: “A Kind resource must always be queried using all three identifiers: namespace, name, and user_id.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/yaml_init.py` around lines 471 - 479, Update the
newer-version branch around _is_newer_skill_version and the update_skill
contract to pass the existing Kind’s namespace and name alongside skill_id and
user_id. Modify update_skill’s Kind lookup to require and match namespace, name,
and user_id, then add coverage asserting all three identity predicates are
included in the query.

Source: Coding guidelines

logger.info(
"Updated versioned public skill: %s (%s -> %s)",
skill_name,
existing.spec.version,
metadata.get("version"),
)
results.append(
{
"kind": "Skill",
"name": skill_name,
"namespace": namespace,
"operation": "updated",
"success": True,
}
)
updated_count += 1
continue
else:
logger.info(f"Skipping existing public skill: {skill_name}")
results.append(
Expand All @@ -448,18 +509,6 @@ def apply_skills_from_directory(
skipped_count += 1
continue

# Create ZIP file in memory from skill folder
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for file_path in skill_folder.rglob("*"):
if file_path.is_file():
# Archive path should be: skill_name/filename
arcname = f"{skill_name}/{file_path.relative_to(skill_folder)}"
zip_file.write(file_path, arcname)

zip_content = zip_buffer.getvalue()
zip_filename = f"{skill_name}.zip"

# Create skill as PUBLIC (user_id=0) using skill_kinds_service
skill = skill_kinds_service.create_skill(
db,
Expand Down Expand Up @@ -487,7 +536,7 @@ def apply_skills_from_directory(
created_count += 1

except Exception as e:
logger.error(f"Failed to create public skill {skill_name}: {e}")
logger.error(f"Failed to apply public skill {skill_name}: {e}")
results.append(
{
"kind": "Skill",
Expand Down
13 changes: 4 additions & 9 deletions backend/app/services/sandbox_file_syncer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
block the attachment upload flow.
"""

import asyncio
import logging
import os
from typing import Optional

import httpx

from shared.utils.attachment_block import sanitize_attachment_filename

logger = logging.getLogger(__name__)

# Configuration
Expand All @@ -39,13 +40,7 @@ def _sanitize_filename(filename: str) -> str:
Returns:
Sanitized filename safe for use in file paths
"""
# Get basename to remove any directory components
safe_name = os.path.basename(filename or "attachment")
# Replace path separators that might have been encoded
safe_name = safe_name.replace("/", "_").replace("\\", "_")
# Remove control characters
safe_name = safe_name.replace("\n", "").replace("\r", "")
return safe_name if safe_name else "attachment"
return sanitize_attachment_filename(filename, fallback="attachment")


def build_sandbox_attachment_path(task_id: int, subtask_id: int, filename: str) -> str:
Expand Down Expand Up @@ -282,7 +277,7 @@ async def sync_attachment_to_sandbox_background(
) -> None:
"""Sync attachment to sandbox in background.

This function is designed to be called from asyncio.create_task()
This function is designed to be scheduled as an asynchronous task.
and handles all exceptions internally.

Args:
Expand Down
19 changes: 11 additions & 8 deletions backend/init_data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@ This directory contains YAML configuration files for initializing the Wegent sys

1. **Auto-scan**: On startup, the backend scans `INIT_DATA_DIR` (default: `/app/init_data`) for all `.yaml` and `.yml` files
2. **Auto-apply**: All resources are loaded and checked against the database
3. **Create-only**: Resources are **only created if they don't exist** - existing resources are **skipped**
4. **User modifications preserved**: Any changes made through the UI/API are **never overwritten** on restart
5. **Order**: Files are processed in alphabetical order (use numeric prefixes for ordering)
3. **Create-only resources**: Existing YAML resources and unversioned Skills are **skipped**
4. **Versioned Skill upgrades**: An existing built-in Skill is updated in place only when the packaged `version` is newer
5. **User modifications preserved**: Same-version resources are never overwritten on restart
6. **Order**: Files are processed in alphabetical order (use numeric prefixes for ordering)

### ⚠️ Important: Non-Destructive Initialization

**The initialization is create-only, NOT create-or-update.**
**YAML resources remain create-only. Built-in Skills opt into upgrades by raising their version.**

- ✅ First startup: Creates all resources from YAML files
- ✅ User modifies a resource (e.g., edits a Ghost's system prompt)
- ✅ Service restart: **User's modifications are preserved** - YAML file is ignored for that resource
- ❌ YAML changes after first startup: **Not applied to existing resources**
- ✅ Service restart with the same Skill version: **User's modifications are preserved**
- ✅ Built-in Skill package with a higher version: the existing public Skill is updated in place, preserving its ID and references
- ❌ YAML changes after first startup: **Not applied to existing YAML resources**

This design ensures:

- User customizations are never lost
- User-owned resources and same-version Skill customizations are preserved
- Versioned public built-in Skills can receive source-controlled fixes
- Safe to restart services without data loss
- YAML files serve as initial templates only
- YAML files remain initial templates only

If you want to update an existing resource to match YAML:

Expand Down
2 changes: 1 addition & 1 deletion backend/init_data/skills/sandbox/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: "Provides read_file/write_file/exec/list_files/read_file/write_file for running process and managing filesystems in the sandbox. Ideal for code testing, file management, and command execution. The sub_claude_agent tool is available for advanced use cases. You MUST load this skill BEFORE use sandbox tools."
displayName: "沙箱环境"
version: "2.1.0"
version: "2.1.1"
author: "Wegent Team"
tags: ["sandbox", "code-execution", "filesystem", "automation"]
bindShells: ["Chat"]
Expand Down
67 changes: 44 additions & 23 deletions backend/init_data/skills/sandbox/download_attachment_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import json
import logging
import os
import re
import time
from typing import Optional
from urllib.parse import urlsplit, urlunsplit

from langchain_core.callbacks import CallbackManagerForToolRun
from pydantic import BaseModel, Field
Expand All @@ -21,6 +23,28 @@

# Default API base URL for attachment downloads
DEFAULT_API_BASE_URL = "http://backend:8000"
_ATTACHMENT_DOWNLOAD_PATH = re.compile(
r"^/api/attachments/(?P<attachment_id>\d+)/download/?$"
)


def _build_download_url(attachment_url: str, api_base_url: str) -> str:
"""Build a URL that accepts the task token available to sandbox tools."""
relative_url = (
attachment_url if attachment_url.startswith("/") else f"/{attachment_url}"
)
parsed = urlsplit(
attachment_url
if attachment_url.startswith(("http://", "https://"))
else relative_url
)
match = _ATTACHMENT_DOWNLOAD_PATH.fullmatch(parsed.path)
if not match:
raise ValueError("Only Wegent attachment download URLs are supported")

backend = urlsplit(api_base_url.rstrip("/"))
executor_path = f"/api/attachments/{match.group('attachment_id')}/executor-download"
return urlunsplit((backend.scheme, backend.netloc, executor_path, "", ""))


class SandboxDownloadAttachmentInput(BaseModel):
Expand Down Expand Up @@ -61,7 +85,7 @@ class SandboxDownloadAttachmentTool(BaseSandboxTool):
"""Tool for downloading files from Wegent Backend to E2B sandbox.

This tool downloads files from Wegent's attachment storage to the
sandbox environment via the /api/attachments/{id}/download endpoint.
sandbox environment via the task-token attachment endpoint.
"""

name: str = "download_attachment"
Expand Down Expand Up @@ -132,8 +156,8 @@ async def _arun(
effective_timeout = timeout_seconds or self.default_download_timeout

logger.info(
f"[SandboxDownloadAttachmentTool] Downloading: {attachment_url} -> {save_path}, "
f"timeout={effective_timeout}s"
"[SandboxDownloadAttachmentTool] Downloading attachment: "
f"save_path={save_path}, timeout={effective_timeout}s"
)

# Emit status update via WebSocket if available
Expand All @@ -159,7 +183,7 @@ async def _arun(

# Get or create sandbox
logger.info(
f"[SandboxDownloadAttachmentTool] Getting or creating sandbox..."
"[SandboxDownloadAttachmentTool] Getting or creating sandbox..."
)
sandbox, error = await sandbox_manager.get_or_create_sandbox(
shell_type=self.default_shell_type,
Expand Down Expand Up @@ -202,17 +226,10 @@ async def _arun(
)
api_base_url = api_base_url.rstrip("/")

# Build full download URL
# attachment_url can be relative (e.g., /api/attachments/123/download) or full URL
if attachment_url.startswith("http://") or attachment_url.startswith(
"https://"
):
download_url = attachment_url
else:
# Ensure attachment_url starts with /
if not attachment_url.startswith("/"):
attachment_url = f"/{attachment_url}"
download_url = f"{api_base_url}{attachment_url}"
# The attachment block exposes the browser download URL. Translate
# that exact Wegent route to the executor route because sandbox tools
# authenticate with a task token rather than a browser login token.
download_url = _build_download_url(attachment_url, api_base_url)
Comment on lines +229 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce task scope when downloading attachments

The tool argument is model-controlled, and this translation sends any numeric attachment ID to executor-download with the task token. That endpoint authenticates task tokens only to a user and filters the attachment by user_id, without checking the token's task_id; consequently, a prompt can enumerate IDs and download attachments belonging to the same user but unrelated tasks. Validate the requested attachment against the active task, or make the executor endpoint enforce the task claim before exposing this route through the tool.

Useful? React with 👍 / 👎.


# Get auth token
auth_token = self.auth_token
Expand All @@ -226,23 +243,27 @@ async def _arun(
await self._emit_tool_status("failed", error_msg)
return result

# Build curl command to download file
# Keep credentials and user-provided paths out of the command string.
# E2B passes these values directly as process environment variables.
curl_cmd = (
f"curl -s -f -L "
f'-H "Authorization: Bearer {auth_token}" '
f'-o "{save_path}" '
f'"{download_url}"'
"curl --silent --show-error --fail --location "
'--header "Authorization: Bearer $WEGENT_ATTACHMENT_TOKEN" '
'--output "$WEGENT_ATTACHMENT_SAVE_PATH" '
'"$WEGENT_ATTACHMENT_DOWNLOAD_URL"'
)

logger.info(
f"[SandboxDownloadAttachmentTool] Executing download via curl from {download_url}"
)
logger.info("[SandboxDownloadAttachmentTool] Executing attachment download")

# Execute curl command
result_obj = await sandbox.commands.run(
cmd=curl_cmd,
cwd="/home/user",
timeout=effective_timeout,
envs={
"WEGENT_ATTACHMENT_TOKEN": auth_token,
"WEGENT_ATTACHMENT_SAVE_PATH": save_path,
"WEGENT_ATTACHMENT_DOWNLOAD_URL": download_url,
},
)

execution_time = time.time() - start_time
Expand Down
1 change: 1 addition & 0 deletions backend/init_data/skills/sandbox/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def _prepare_base_params(
"timeout": config.get("timeout", 7200),
"auth_token": context.auth_token, # For skill downloads in sandbox
"skill_identity_token": context.skill_identity_token,
"load_skill_tool": context.load_skill_tool,
}

@property
Expand Down
Loading
Loading