Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,20 @@ Digital employees can serve users directly over IM channels: users chat with emp
- Channel credentials (bot tokens/secrets) are stored Fernet-encrypted and never returned by any API;
- Binding management is restricted to admins or the binding creator; mounting an employee exposes it to all users of that channel — grant with care.

## Forward Proxy (enterprise private deployments)

When outbound traffic must go through a forward proxy, set three variables in `backend/.env` — model calls, tools/MCP, channel connections (WeChat/WeCom/Feishu/DingTalk), sandboxed skill code, and pip installs all honor them:

```dotenv
HTTP_PROXY="http://proxy.corp.example:8080"
HTTPS_PROXY="http://proxy.corp.example:8080"
NO_PROXY=".corp.example" # bypass list: domain suffix / exact host / *; private IPs and localhost always connect directly
```

- **Internal model services are never proxied**: private ranges (10./172.16./192.168./127.) and single-label hosts connect directly; an internal domain suffix (e.g. `.corp.example`) covers all subdomains with one entry;
- Explicit configuration wins over process environment variables; when unset, env vars are honored as usual (httpx trust_env, websockets, pip);
- With the sandbox network policy in allowlist mode, the proxy host is added to the allowed domains automatically.

## Project Structure

```text
Expand Down
14 changes: 14 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@ curl.exe http://127.0.0.1:5173/api/health

外部业务系统可以通过员工级 API Key 调用数字员工、持续会话、Harness v2 Run、SOP、知识、技能、工具和定时任务。完整的鉴权边界、接口清单、SSE、Webhook 与调用示例见 [数字员工开放 API v1](docs/open-api-v1.md)。

## 正向代理(企业私有化部署)

企业内网访问外网需要正向代理时,在 `backend/.env` 配置三项即可,全栈(模型调用、工具/MCP、微信/企微/飞书/钉钉渠道、沙箱内技能代码、pip 依赖安装)自动生效:

```dotenv
HTTP_PROXY="http://proxy.corp.example:8080"
HTTPS_PROXY="http://proxy.corp.example:8080"
NO_PROXY=".corp.example" # 绕过名单:域名后缀/精确主机/*;内网 IP 与 localhost 恒自动直连,无需列入
```

- **内网模型服务不会被代理出去**:`10./172.16./192.168./127.` 私网地址与无点主机名恒直连;内网域名只写一次后缀(如 `.corp.example`)即覆盖全部子域名;
- 显式配置优先于系统环境变量;未配置时沿用进程环境变量(httpx trust_env、websockets、pip 均识别);
- 沙箱网络策略为 allowlist 时,代理主机会自动并入允许域。

## 项目结构

```text
Expand Down
12 changes: 12 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ GENERAL_SKILL_RUNTIME_PYTHON=""
GENERAL_SKILL_RUNTIME_VENV=""
GENERAL_SKILL_RUNTIME_PACKAGES="requests,httpx"
GENERAL_SKILL_RUNTIME_AUTO_INSTALL="true"
# 通用技能运行时依赖安装:企业内网 PyPI 镜像源地址(留空用官方源)
GENERAL_SKILL_PIP_INDEX_URL=""
# 单次 pip 安装超时(秒)
GENERAL_SKILL_PIP_TIMEOUT_SECONDS="180"
# 是否允许联网安装依赖(锁定/离线环境设为 false)
GENERAL_SKILL_NETWORK_INSTALL="true"
# 正向代理(企业私有化外网出口,留空不走代理):配置后模型/工具/渠道/pip 全栈生效
HTTP_PROXY=""
HTTPS_PROXY=""
# 代理绕过名单(逗号分隔):精确主机、.域名后缀、*.域名后缀 或 * 全部直连;
# 内网 IP(10./172.16./192.168. 等)与 localhost 无需列入,恒自动直连
NO_PROXY=""
CHANNEL_SECRET=""
STAFFDECK_ROLE="all"
WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com"
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
ToolTestRequest,
ToolUpdateRequest,
)
from app.net_proxy import httpx_proxy_kwargs

router = APIRouter(prefix="/api/enterprise/tools", tags=["enterprise:tools"])
mcp_router = APIRouter(prefix="/api/enterprise/mcp-servers", tags=["enterprise:mcp-servers"])
Expand Down Expand Up @@ -281,7 +282,7 @@ def probe_tool(
)
timeout_seconds = _probe_timeout_seconds(request)
try:
with httpx.Client(timeout=timeout_seconds) as client:
with httpx.Client(timeout=timeout_seconds, **httpx_proxy_kwargs(url)) as client:
if request.method.upper() == "GET":
request_url, request_kwargs = prepare_get_request(url, request.sample_arguments)
response = client.request(
Expand Down
7 changes: 4 additions & 3 deletions backend/app/channels/adapters/dingtalk.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from app.config import get_settings
from app.db import engine
from app.db.models import ChannelBinding
from app.net_proxy import httpx_proxy_kwargs

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,7 +73,7 @@ class DingTalkTokenProvider:
"""按绑定缓存 access token;sessionWebhook 出站不需要它,服务端 API 才需要。"""

def __init__(self, *, client_factory: Callable[[], httpx.Client] | None = None):
self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0))
self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://api.dingtalk.com")))
self._cache: dict[tuple[str, str, int], tuple[str, float]] = {}
self._lock = threading.Lock()
self._key_locks: dict[tuple[str, str, int], threading.Lock] = {}
Expand Down Expand Up @@ -301,7 +302,7 @@ def validate_dingtalk_credentials(
client_secret = client_secret.strip()
if not client_id or not client_secret:
raise DingTalkPermanentError("钉钉 Client ID 与 Client Secret 均不能为空")
factory = client_factory or (lambda: httpx.Client(timeout=10.0))
factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://api.dingtalk.com")))
body = {
"clientId": client_id,
"clientSecret": client_secret,
Expand Down Expand Up @@ -373,7 +374,7 @@ def __init__(
client_factory: Callable[[], httpx.Client] | None = None,
token_provider: DingTalkTokenProvider | None = None,
):
self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0))
self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0, **httpx_proxy_kwargs("https://api.dingtalk.com")))
self._tokens = token_provider or DingTalkTokenProvider(client_factory=self._client_factory)

def normalize(self, raw: dict[str, Any]) -> ChannelInbound | None:
Expand Down
7 changes: 4 additions & 3 deletions backend/app/channels/adapters/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from app.config import get_settings
from app.db.models import ChannelBinding
from app.net_proxy import httpx_proxy_kwargs

FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
TOKEN_REFRESH_SKEW_SECONDS = 300
Expand Down Expand Up @@ -51,7 +52,7 @@ class FeishuTransientError(FeishuSendError):

class FeishuTokenProvider:
def __init__(self, *, client_factory: Callable[[], httpx.Client] | None = None):
self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0))
self._client_factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://open.feishu.cn")))
self._cache: dict[tuple[str, str, int], tuple[str, float]] = {}
self._lock = threading.Lock()
self._key_locks: dict[tuple[str, str, int], threading.Lock] = {}
Expand Down Expand Up @@ -126,7 +127,7 @@ def validate_feishu_credentials(
*,
client_factory: Callable[[], httpx.Client] | None = None,
) -> dict[str, str]:
factory = client_factory or (lambda: httpx.Client(timeout=10.0))
factory = client_factory or (lambda: httpx.Client(timeout=10.0, **httpx_proxy_kwargs("https://open.feishu.cn")))
try:
with factory() as client:
token_response = client.post(
Expand Down Expand Up @@ -175,7 +176,7 @@ def __init__(
token_provider: FeishuTokenProvider | None = None,
client_factory: Callable[[], httpx.Client] | None = None,
):
self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0))
self._client_factory = client_factory or (lambda: httpx.Client(timeout=15.0, **httpx_proxy_kwargs("https://open.feishu.cn")))
self._tokens = token_provider or FeishuTokenProvider(client_factory=self._client_factory)

def normalize(self, raw: dict[str, Any]):
Expand Down
4 changes: 3 additions & 1 deletion backend/app/channels/adapters/wechat.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from app.config import get_settings
from app.db import engine
from app.db.models import ChannelBinding, utc_now
from app.net_proxy import httpx_proxy_kwargs

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -102,6 +103,7 @@ async def _download_wechat_cdn_httpx(url: str) -> tuple[bytes, str]:
verify=certifi.where(),
http2=False,
timeout=15.0,
**httpx_proxy_kwargs(url),
) as client, client.stream("GET", url) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "")
Expand Down Expand Up @@ -320,7 +322,7 @@ def __init__(
):
self.base_url = base_url.rstrip("/")
self.bot_token = bot_token
self._client = httpx.Client(transport=transport)
self._client = httpx.Client(transport=transport, **httpx_proxy_kwargs(base_url))

@classmethod
def for_binding(cls, binding: ChannelBinding) -> WeChatClient:
Expand Down
9 changes: 8 additions & 1 deletion backend/app/channels/feishu_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,14 @@ async def _disconnect_and_reconnect(self, *, expected_conn=None):
control.emit("DISCONNECTED")
return await super()._disconnect_and_reconnect(expected_conn=expected_conn)

client = ProductionClient(app_id, app_secret, event_handler=dispatcher)
# 正向代理:显式配置走 proxy_url,否则 trust_env_proxy=True 读环境变量(含 NO_PROXY 绕过)
from app.net_proxy import proxy_for_url

feishu_proxy = proxy_for_url("https://open.feishu.cn")
client_kwargs = (
{"proxy_url": feishu_proxy} if feishu_proxy else {"trust_env_proxy": True}
)
client = ProductionClient(app_id, app_secret, event_handler=dispatcher, **client_kwargs)

def request_stop() -> None:
async def shutdown() -> None:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/channels/schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Any, Optional
from typing import Optional

from pydantic import BaseModel
from sqlmodel import Session, select
Expand Down
6 changes: 6 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ class Settings(BaseSettings):
general_skill_pip_index_url: str = ""
general_skill_pip_timeout_seconds: int = 180
general_skill_network_install: bool = True
# 正向代理(企业私有化外网出口):显式配置后启动期收敛进进程环境,
# httpx/websockets/pip 全栈生效;内网地址(私网 IP/localhost)恒直连
http_proxy: str = ""
https_proxy: str = ""
# 绕过名单(逗号分隔):支持 精确主机 / .域名后缀 / *.域名后缀 / * 全部直连
no_proxy: str = ""
channel_secret: str = ""
staffdeck_role: str = "all"
wechat_ilink_base_url: str = "https://ilinkai.weixin.qq.com"
Expand Down
10 changes: 10 additions & 0 deletions backend/app/harness/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,12 @@ def _write_srt_settings(
domains: list[str] = []
elif network_mode == "allowlist":
domains = [item.strip() for item in allowed_domains if item.strip()]
# 配置正向代理后,沙箱内外网流量须经代理:代理主机自动并入允许域
from app.net_proxy import proxy_host_for_allowlist

proxy_host = proxy_host_for_allowlist()
if proxy_host and proxy_host not in domains:
domains.append(proxy_host)
else:
if not _srt_supports_allow_all():
raise HarnessExecutionError(
Expand Down Expand Up @@ -804,6 +810,8 @@ def _bubblewrap_argv(
if key in {
"ARGUMENTS", "QUERY", "SKILL_WORKSPACE", "ARTIFACT_DIR", "SKILL_SLUG", "SKILL_NAME",
"USER_ID", "SKILL_FILES_JSON", "SSL_CERT_FILE", "PIP_CERT",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"http_proxy", "https_proxy", "no_proxy",
}
}
for key, value in allowed_env.items():
Expand Down Expand Up @@ -844,6 +852,8 @@ def _managed_process_environment(env: dict[str, str] | None) -> dict[str, str]:
"PATH", "HOME", "PWD", "TMPDIR", "LANG", "LC_ALL",
"ARGUMENTS", "QUERY", "SKILL_WORKSPACE", "ARTIFACT_DIR", "SKILL_SLUG",
"SKILL_NAME", "USER_ID", "SKILL_FILES_JSON", "SSL_CERT_FILE", "PIP_CERT",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"http_proxy", "https_proxy", "no_proxy",
}
}
return {**baseline, **allowed}
Expand Down
20 changes: 19 additions & 1 deletion backend/app/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.config import get_settings
from app.db.models import ModelConfig
from app.llm.model_protocols import ModelApiProtocol
from app.net_proxy import httpx_proxy_kwargs
from app.llm.output_policy import (
operation_empty_response_retries,
operation_output_tokens,
Expand Down Expand Up @@ -90,6 +91,14 @@ def public_detail(self) -> dict[str, Any]:
REASONING_TOKEN_ESCALATION_CEILING = 32768


def _maybe_proxied_http_client(base_url: str, timeout_seconds: float) -> httpx.Client | None:
"""显式代理配置(或绕过名单强制直连)时,构造注入好的 http_client;否则 None(SDK 默认 trust_env)。"""
kwargs = httpx_proxy_kwargs(base_url)
if not kwargs:
return None
return httpx.Client(timeout=timeout_seconds, **kwargs)


def _escalate_reasoning_token_budget(current_max_tokens: int) -> int:
"""Double the token budget for the next retry, but never below the ceiling.

Expand Down Expand Up @@ -125,18 +134,23 @@ def __init__(self, model_config: ModelConfig):
or DEFAULT_MODEL_API_TIMEOUT_SECONDS
)
self.base_url = str(model_config.base_url or "")
# 正向代理:显式配置时按目标 base_url 注入(绕过名单/私网自动直连),
# 未配置时 SDK 走 trust_env 读环境变量(HTTP_PROXY/NO_PROXY)
proxied_http_client = _maybe_proxied_http_client(self.base_url, self.timeout_seconds)
if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS:
self.client = OpenAI(
api_key=api_key,
base_url=self.base_url,
timeout=self.timeout_seconds,
**({"http_client": proxied_http_client} if proxied_http_client else {}),
)
self.driver = ChatCompletionsDriver(self.client)
elif protocol is ModelApiProtocol.OPENAI_RESPONSES:
self.client = OpenAI(
api_key=api_key,
base_url=self.base_url,
timeout=self.timeout_seconds,
**({"http_client": proxied_http_client} if proxied_http_client else {}),
)
self.driver = OpenAIResponsesDriver(self.client)
elif protocol is ModelApiProtocol.ANTHROPIC_MESSAGES:
Expand All @@ -145,6 +159,8 @@ def __init__(self, model_config: ModelConfig):
"timeout": self.timeout_seconds,
"max_retries": 0,
}
if proxied_http_client:
kwargs["http_client"] = proxied_http_client
if self.base_url:
# Anthropic's SDK always appends /v1/messages. If an operator
# already configured a /v1 API root, remove only that suffix
Expand All @@ -160,7 +176,9 @@ def __init__(self, model_config: ModelConfig):
self.client = Anthropic(**kwargs)
self.driver = AnthropicMessagesDriver(self.client)
elif protocol is ModelApiProtocol.GEMINI_GENERATE_CONTENT:
self.client = httpx.Client(timeout=self.timeout_seconds)
self.client = httpx.Client(
timeout=self.timeout_seconds, **httpx_proxy_kwargs(self.base_url)
)
self.driver = GeminiGenerateContentDriver(
self.client,
self.base_url,
Expand Down
3 changes: 3 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from app.config import get_settings
from app.db import engine, init_db
from app.db.seed import seed_demo_data
from app.net_proxy import apply_proxy_env
from app.public_api import create_public_api_app
from app.public_api.jobs import cleanup_public_api_records, recover_public_jobs
from app.public_api.maintenance import start_public_api_maintenance, stop_public_api_maintenance
Expand Down Expand Up @@ -66,6 +67,8 @@
def on_startup() -> None:
acquire_runtime_instance_lock()
try:
# 正向代理配置先收敛进进程环境,后续所有网络栈(httpx/WS/pip)按其生效
apply_proxy_env()
start_async_jobs()
init_db()
with Session(engine) as db:
Expand Down
Loading