diff --git a/README.md b/README.md index 4c91f1cf..4978a30c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major ## 🏗️ Architecture -CowAgent Architecture +CowAgent Architecture CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py index 9da349d1..066549dc 100644 --- a/agent/memory/summarizer.py +++ b/agent/memory/summarizer.py @@ -462,13 +462,12 @@ class MemoryFlushManager: daily_content=daily_content or "(no recent daily records)", ) from agent.protocol.models import LLMRequest - # Scale max_tokens based on input size to avoid truncating large MEMORY.md - input_chars = len(memory_content) + len(daily_content) - dream_max_tokens = max(2000, min(input_chars, 8000)) + # No output cap: the prompt already keeps MEMORY.md concise (~50 + # items), so a hard max_tokens would only risk truncating a large + # rewrite. Let the model use its default output budget. request = LLMRequest( messages=[{"role": "user", "content": user_msg}], temperature=0.3, - max_tokens=dream_max_tokens, stream=False, system=_dream_system_prompt(), ) diff --git a/agent/skills/service.py b/agent/skills/service.py index a34a546f..95cfb9bb 100644 --- a/agent/skills/service.py +++ b/agent/skills/service.py @@ -34,6 +34,27 @@ class SkillService: """ self.manager = skill_manager + def _safe_skill_dir(self, name: str) -> str: + """Derive and validate the skill directory path. + + Ensures the resolved path stays within the custom_dir root, + preventing path traversal via names like ``../escaped``. + + :raises ValueError: if the name would escape the skills root. + """ + if not name or not name.strip(): + raise ValueError("skill name is required") + # Reject obvious traversal components. + if ".." in name or name.startswith("/") or name.startswith("\\"): + raise ValueError(f"invalid skill name (path traversal detected): {name!r}") + skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name)) + root = os.path.realpath(self.manager.custom_dir) + if not skill_dir.startswith(root + os.sep) and skill_dir != root: + raise ValueError( + f"skill name {name!r} resolves outside the skills directory" + ) + return skill_dir + # ------------------------------------------------------------------ # query # ------------------------------------------------------------------ @@ -107,7 +128,7 @@ class SkillService: if not files: raise ValueError("skill files list is empty") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) tmp_dir = skill_dir + ".tmp" if os.path.exists(tmp_dir): @@ -146,7 +167,7 @@ class SkillService: raise ValueError("package url is required") url = files[0]["url"] - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "package.zip") @@ -217,7 +238,7 @@ class SkillService: if not name: raise ValueError("skill name is required") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) if os.path.exists(skill_dir): shutil.rmtree(skill_dir) logger.info(f"[SkillService] delete: removed directory {skill_dir}") diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 19878f6d..170d2175 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -17,11 +17,14 @@ Provider resolution: """ import base64 +import ipaddress import os +import socket import subprocess import tempfile from dataclasses import dataclass, field from typing import Any, Dict, List, Optional +from urllib.parse import urlparse import requests @@ -654,6 +657,40 @@ class Vision(BaseTool): return api_base return api_base.rstrip("/") + "/v1" + @staticmethod + def _validate_url_safe(url: str) -> None: + """Reject URLs that target private/loopback/link-local addresses (SSRF guard). + + Resolves the hostname to its IP address(es) and blocks any that fall + into non-public ranges. Also rejects URLs with no host, non-HTTP(S) + schemes, or hosts that fail DNS resolution. + + Raises: + ValueError: if the URL targets a disallowed address. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") + + hostname = parsed.hostname + if not hostname: + raise ValueError("URL has no hostname") + + try: + # Resolve all addresses for the hostname. + addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + raise ValueError(f"Cannot resolve hostname: {hostname}") + + for family, _, _, _, sockaddr in addr_infos: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError( + f"URL resolves to a non-public address ({ip_str}), " + f"request blocked for security" + ) + def _build_image_content(self, image: str) -> dict: """ Build the image_url content block. @@ -661,6 +698,7 @@ class Vision(BaseTool): so every bot backend can consume them without extra downloads. """ if image.startswith(("http://", "https://")): + self._validate_url_safe(image) return self._download_to_data_url(image) if not os.path.isfile(image): diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 9104a38e..e2a65c5b 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -519,7 +519,10 @@ class ChatChannel(Channel): def cancel_session(self, session_id): with self.lock: if session_id in self.sessions: - for future in self.futures[session_id]: + # futures[session_id] is only created in consume() when a task is + # dispatched, so it may be absent if cancel happens right after + # produce() but before the first dispatch. Default to []. + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: @@ -529,7 +532,7 @@ class ChatChannel(Channel): def cancel_all_session(self): with self.lock: for session_id in self.sessions: - for future in self.futures[session_id]: + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: diff --git a/channel/web/chat.html b/channel/web/chat.html index 73719f71..a77eb2a1 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1198,6 +1198,66 @@ + + + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 9311046b..a618265d 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -866,6 +866,14 @@ font-size: 11px; flex-shrink: 0; } +/* "Custom" row is an add-new action: trailing + instead of ✓. */ +.vendor-picker-add-mark { + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} /* Chat Input */ #chat-input { diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 87b1c8b2..09f09e4d 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -32,6 +32,15 @@ const I18N = { models_clear_credential: '清除凭据', models_base_default_hint: '留空将使用官方默认地址', models_base_default: '默认', + models_custom_vendor_label: '自定义', + models_custom_name: '名称', + models_custom_delete: '删除', + models_custom_delete_confirm_title: '删除自定义厂商', + models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', + models_custom_name_required: '请填写名称', + models_custom_base_required: '请填写 API Base', + models_custom_edit_title: '编辑自定义厂商', + models_custom_add_title: '添加自定义厂商', models_capability_chat: '主模型', models_capability_chat_desc: '用于基础对话和 Agent 推理', models_capability_vision: '图像理解', @@ -226,10 +235,10 @@ const I18N = { menu_logs: 'Logs', models_title: 'Models', models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place', - models_section_vendors: 'Vendor Credentials', + models_section_vendors: 'Provider Credentials', models_section_vendors_desc: 'Configured once, shared by multiple model capabilities', models_section_capabilities: 'Capabilities', - models_add_vendor: 'Add Vendor', + models_add_vendor: 'Add Provider', models_provider: 'Provider', models_model: 'Model', models_voice: 'Voice', @@ -239,6 +248,15 @@ const I18N = { models_clear_credential: 'Clear credentials', models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default: 'Default', + models_custom_vendor_label: 'Custom', + models_custom_name: 'Name', + models_custom_delete: 'Delete', + models_custom_delete_confirm_title: 'Delete custom provider', + models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', + models_custom_name_required: 'Name is required', + models_custom_base_required: 'API Base is required', + models_custom_edit_title: 'Edit custom provider', + models_custom_add_title: 'Add custom provider', models_capability_chat: 'Main Model', models_capability_chat_desc: 'Used for basic chat and agent reasoning', models_capability_vision: 'Image Understanding', @@ -280,8 +298,8 @@ const I18N = { models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.', models_embedding_saved_ok: 'Go', models_pick_provider: 'Pick a provider', - models_clear_confirm_title: 'Clear vendor credentials', - models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.', + models_clear_confirm_title: 'Clear provider credentials', + models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.', cancel: 'Cancel', save: 'Save', ok: 'OK', @@ -4799,13 +4817,22 @@ function renderModelsView() { MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); } +// True when a provider card is one of the expanded custom (OpenAI-compatible) +// providers (id "custom:") — shown in the vendor grid alongside built-in +// vendors, but edited via the dedicated custom-provider modal. +function isCustomProviderCard(p) { + return !!(p && p.is_custom && p.custom_name); +} + // ---------- Vendor section (Layer 1) ----------------------------------- function renderVendorsSection() { const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; - const configured = modelsState.providers.filter(p => p.configured); + // Custom providers always show once created (even without an api key, + // e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured. + const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p)); const header = `
@@ -4816,7 +4843,6 @@ function renderVendorsSection() {

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

- ${configured.length}/${modelsState.providers.length} `; let body; @@ -4842,8 +4868,13 @@ function renderVendorsSection() { function renderVendorChip(p) { // The masked API key is intentionally not surfaced here; it is shown // inside the edit modal so the chip stays uncluttered and scannable. + // Custom providers open their dedicated modal (name + base + key); + // their ids are server-generated hex, safe to inline. + const onclick = isCustomProviderCard(p) + ? `openCustomProviderModal('${escapeHtml(p.custom_id)}')` + : `openVendorModal('${escapeHtml(p.id)}')`; return ` -