4344 lines
168 KiB
Python
4344 lines
168 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Project-local integration layer for xai-org/grok-build.
|
||
|
||
The upstream Grok Build binary remains the execution engine. This module owns
|
||
the project-scoped runtime state, installs the official Windows release, maps
|
||
the backend-managed model into Grok's TOML format without persisting its API
|
||
key in TOML, and exposes interactive, headless, and ACP launch modes.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import copy
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import tomllib
|
||
import urllib.error
|
||
import urllib.request
|
||
import uuid
|
||
from datetime import date, datetime, time as datetime_time
|
||
from dataclasses import asdict, dataclass, replace
|
||
from pathlib import Path
|
||
from typing import Callable, Iterable, Mapping, Sequence
|
||
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
||
|
||
|
||
PROJECT_DIR = Path(__file__).resolve().parent
|
||
DEFAULT_AI_SETTINGS = PROJECT_DIR / "ai_settings.local.json"
|
||
DEFAULT_INTEGRATION_SETTINGS = PROJECT_DIR / "grok_build_settings.json"
|
||
OFFICIAL_BASE_URL = "https://x.ai/cli"
|
||
OFFICIAL_FALLBACK_URL = "https://storage.googleapis.com/grok-build-public-artifacts/cli"
|
||
MODEL_PROFILE = "wecom-backend"
|
||
MODEL_API_KEY_ENV = "WECOM_GROK_API_KEY"
|
||
MANAGED_MODELS_KEYS = frozenset(
|
||
{
|
||
"default",
|
||
"allowed_models",
|
||
"web_search",
|
||
"session_summary",
|
||
"image_description",
|
||
"prompt_suggestion",
|
||
}
|
||
)
|
||
MANAGED_UI_KEYS = frozenset({"prompt_suggestions", "fork_secondary_model"})
|
||
PINNED_SUBAGENT_NAMES = frozenset(
|
||
{"general-purpose", "explore", "plan"}
|
||
)
|
||
CUSTOM_MODEL_ENVIRONMENT = {
|
||
"GROK_DEFAULT_MODEL": MODEL_PROFILE,
|
||
"GROK_WEB_SEARCH_MODEL": MODEL_PROFILE,
|
||
"GROK_SESSION_SUMMARY_MODEL": MODEL_PROFILE,
|
||
"GROK_IMAGE_DESCRIPTION_MODEL": MODEL_PROFILE,
|
||
"GROK_PROMPT_SUGGESTIONS_MODEL": MODEL_PROFILE,
|
||
"GROK_SUGGESTIONS_AI_MODEL": MODEL_PROFILE,
|
||
"GROK_GOAL_USE_CURRENT_MODEL_ONLY": "1",
|
||
"GROK_PROMPT_SUGGESTIONS": "0",
|
||
"GROK_SUGGESTIONS": "0",
|
||
"GROK_SUGGESTIONS_AI": "0",
|
||
"GROK_AGENT": "grok-build",
|
||
"GROK_MEMORY": "0",
|
||
"GROK_IMAGE_GEN": "0",
|
||
"GROK_IMAGE_EDIT": "0",
|
||
"GROK_VIDEO_GEN": "0",
|
||
}
|
||
XAI_CREDENTIAL_ENV_VARS = frozenset(
|
||
{
|
||
"XAI_API_KEY",
|
||
"XAI_API_TOKEN",
|
||
"XAI_ACCESS_TOKEN",
|
||
"GROK_API_KEY",
|
||
"GROK_CODE_XAI_API_KEY",
|
||
"GROK_AUTH",
|
||
"GROK_DEPLOYMENT_KEY",
|
||
"GROK_EXTRA_AUTH_KEY",
|
||
}
|
||
)
|
||
UNMANAGED_MODEL_ROUTE_ENV_VARS = frozenset(
|
||
{
|
||
"GROK_MODELS_BASE_URL",
|
||
"GROK_MODELS_LIST_URL",
|
||
"GROK_XAI_API_BASE_URL",
|
||
"GROK_CLI_CHAT_PROXY_BASE_URL",
|
||
"GROK_IMAGE_GEN_MODEL_OVERRIDE",
|
||
}
|
||
)
|
||
USER_AGENT = "ZhenYangTang-RPA-Grok-Bridge/1.0"
|
||
MANAGED_CONFIG_BEGIN = "# >>> 企业微信 RPA 自动配置(请勿手工修改此区块)"
|
||
MANAGED_CONFIG_END = "# <<< 企业微信 RPA 自动配置结束"
|
||
MANAGED_MCP_PREFIX = "wecom-rpa-"
|
||
CUSTOMER_SERVICE_MCP_NAME = f"{MANAGED_MCP_PREFIX}customer-service"
|
||
VERSION_PATTERN = re.compile(
|
||
r"\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\Z"
|
||
)
|
||
|
||
|
||
class GrokBuildError(RuntimeError):
|
||
"""Raised for an actionable Grok Build integration failure."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelProfile:
|
||
compatible: bool
|
||
profile: str
|
||
model: str
|
||
base_url: str
|
||
api_backend: str
|
||
auth_scheme: str
|
||
temperature: float
|
||
max_completion_tokens: int
|
||
context_window: int
|
||
reason: str
|
||
source_backend: str = ""
|
||
source_base_url: str = ""
|
||
adapter_instance_id: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelSyncResult:
|
||
compatible: bool
|
||
configured: bool
|
||
profile: str
|
||
model: str
|
||
base_url: str
|
||
api_backend: str
|
||
config_path: str
|
||
synced_at: str
|
||
message: str
|
||
source_base_url: str = ""
|
||
source_api_backend: str = ""
|
||
effective_base_url: str = ""
|
||
effective_api_backend: str = ""
|
||
adapter_instance_id: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ModelEndpointProbe:
|
||
ok: bool
|
||
checked: bool
|
||
api_backend: str
|
||
endpoint: str
|
||
http_status: int | None
|
||
latency_ms: int
|
||
message: str
|
||
detected_protocol: str = ""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RuntimeStatus:
|
||
installed: bool
|
||
binary_path: str
|
||
version: str
|
||
authenticated: bool
|
||
runtime_home: str
|
||
model_configured: bool
|
||
model_compatible: bool
|
||
model_name: str
|
||
model_message: str
|
||
model_api_backend: str = ""
|
||
model_effective_base_url: str = ""
|
||
adapter_live: bool = False
|
||
warnings: tuple[str, ...] = ()
|
||
|
||
|
||
def _toml_string(value: object) -> str:
|
||
return json.dumps(str(value), ensure_ascii=False)
|
||
|
||
|
||
def _utc_timestamp() -> str:
|
||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
|
||
def _default_runtime_home(project_dir: Path) -> Path:
|
||
project_key = hashlib.sha256(
|
||
os.path.normcase(str(project_dir.resolve())).encode("utf-8")
|
||
).hexdigest()[:16]
|
||
project_name = re.sub(r"[^A-Za-z0-9_-]+", "-", project_dir.name).strip("-")
|
||
project_name = project_name or "project"
|
||
if os.name == "nt":
|
||
data_root = Path(
|
||
os.environ.get("LOCALAPPDATA")
|
||
or (Path.home() / "AppData" / "Local")
|
||
)
|
||
else:
|
||
data_root = Path(
|
||
os.environ.get("XDG_DATA_HOME")
|
||
or (Path.home() / ".local" / "share")
|
||
)
|
||
return (
|
||
data_root
|
||
/ "ZhenYangTang"
|
||
/ "WeChatRPA"
|
||
/ "GrokBuild"
|
||
/ f"{project_name}-{project_key}"
|
||
/ "custom-agent-only-v1"
|
||
)
|
||
|
||
|
||
DEFAULT_RUNTIME_HOME = _default_runtime_home(PROJECT_DIR)
|
||
_RUNTIME_LOCKS_GUARD = threading.Lock()
|
||
_RUNTIME_LOCKS: dict[str, threading.RLock] = {}
|
||
_MODEL_PROBE_STATE_GUARD = threading.Lock()
|
||
_MODEL_PROBE_LOCKS: dict[str, threading.Lock] = {}
|
||
_MODEL_PROBE_CACHE: dict[
|
||
str,
|
||
tuple[str, float, ModelEndpointProbe],
|
||
] = {}
|
||
|
||
|
||
def _runtime_scope_key(runtime_home: Path) -> str:
|
||
return os.path.normcase(str(runtime_home.resolve()))
|
||
|
||
|
||
def _shared_runtime_lock(runtime_home: Path) -> threading.RLock:
|
||
key = _runtime_scope_key(runtime_home)
|
||
with _RUNTIME_LOCKS_GUARD:
|
||
lock = _RUNTIME_LOCKS.get(key)
|
||
if lock is None:
|
||
lock = threading.RLock()
|
||
_RUNTIME_LOCKS[key] = lock
|
||
return lock
|
||
|
||
|
||
def _shared_model_probe_lock(runtime_home: Path) -> threading.Lock:
|
||
key = _runtime_scope_key(runtime_home)
|
||
with _MODEL_PROBE_STATE_GUARD:
|
||
lock = _MODEL_PROBE_LOCKS.get(key)
|
||
if lock is None:
|
||
lock = threading.Lock()
|
||
_MODEL_PROBE_LOCKS[key] = lock
|
||
return lock
|
||
|
||
|
||
def parse_streaming_event(line: str) -> tuple[str, str]:
|
||
"""Project one Grok streaming-json line into a display category and text."""
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
return "empty", ""
|
||
try:
|
||
event = json.loads(stripped)
|
||
except json.JSONDecodeError:
|
||
return "raw", stripped
|
||
if not isinstance(event, dict):
|
||
return "raw", stripped
|
||
event_type = str(event.get("type") or "event")
|
||
if event_type in {"text", "thought"}:
|
||
return event_type, str(event.get("data") or "")
|
||
if event_type == "error":
|
||
return "error", str(event.get("message") or event.get("data") or "Grok 执行失败")
|
||
if event_type == "end":
|
||
session_id = str(event.get("sessionId") or "")
|
||
stop_reason = str(event.get("stopReason") or "EndTurn")
|
||
turns = event.get("num_turns")
|
||
parts = [f"完成:{stop_reason}"]
|
||
if turns is not None:
|
||
parts.append(f"{turns} 轮")
|
||
if session_id:
|
||
parts.append(f"会话 {session_id}")
|
||
return "end", " · ".join(parts)
|
||
return event_type, stripped
|
||
|
||
|
||
class GrokBuildManager:
|
||
"""Manage an isolated Grok Build sidecar for this project."""
|
||
|
||
def __init__(
|
||
self,
|
||
project_dir: str | os.PathLike[str] = PROJECT_DIR,
|
||
runtime_home: str | os.PathLike[str] | None = None,
|
||
ai_settings_file: str | os.PathLike[str] | None = None,
|
||
integration_settings_file: str | os.PathLike[str] | None = None,
|
||
):
|
||
self.project_dir = Path(project_dir).resolve()
|
||
self.asset_home = (self.project_dir / ".grok-build").resolve()
|
||
self._uses_default_runtime_home = runtime_home is None
|
||
self.runtime_home = Path(
|
||
runtime_home or _default_runtime_home(self.project_dir)
|
||
).resolve()
|
||
self._uses_default_ai_settings = ai_settings_file is None
|
||
self.ai_settings_file = Path(
|
||
ai_settings_file or (self.project_dir / "ai_settings.local.json")
|
||
).resolve()
|
||
if integration_settings_file is None:
|
||
self.integration_defaults_file: Path | None = (
|
||
self.project_dir / "grok_build_settings.json"
|
||
).resolve()
|
||
self.integration_settings_file = (
|
||
self.runtime_home / "integration_settings.json"
|
||
)
|
||
else:
|
||
self.integration_defaults_file = None
|
||
self.integration_settings_file = Path(integration_settings_file).resolve()
|
||
self.user_home = Path.home()
|
||
self.binary_store_home = (
|
||
self.asset_home if self._uses_default_runtime_home else self.runtime_home
|
||
)
|
||
self.bin_dir = self.binary_store_home / "bin"
|
||
self.binary_path = self.bin_dir / ("grok.exe" if os.name == "nt" else "grok")
|
||
self.agent_alias_path = self.bin_dir / ("agent.exe" if os.name == "nt" else "agent")
|
||
self.user_config_file = self.runtime_home / "config.toml"
|
||
# Custom models belong in Grok's normal user config. The upstream
|
||
# `managed_config.toml` filename is reserved for signed enterprise
|
||
# policy and can be refreshed or removed by the runtime.
|
||
self.managed_config_file = self.user_config_file
|
||
self.legacy_managed_config_file = self.runtime_home / "managed_config.toml"
|
||
self.sync_state_file = self.runtime_home / "model_sync.json"
|
||
self.install_state_file = self.binary_store_home / "install.json"
|
||
self._runtime_sync_lock = _shared_runtime_lock(self.runtime_home)
|
||
self._validated_binary_fingerprint: tuple[str, int, int] | None = None
|
||
self._model_probe_scope = _runtime_scope_key(self.runtime_home)
|
||
self._model_probe_lock = _shared_model_probe_lock(self.runtime_home)
|
||
self.migration_warnings: list[str] = []
|
||
if self._uses_default_runtime_home:
|
||
self._migrate_legacy_runtime_state()
|
||
|
||
def _migrate_legacy_runtime_state(self) -> None:
|
||
"""Ignore workspace-era state instead of importing old model sessions.
|
||
|
||
The strict Agent runtime must start without xAI auth or sessions that
|
||
may remember a Grok model. The old files are deliberately left in
|
||
place for manual recovery, but are never placed under ``GROK_HOME``.
|
||
"""
|
||
legacy = self.asset_home
|
||
if not legacy.is_dir() or legacy == self.runtime_home:
|
||
return
|
||
excluded = {
|
||
"bin",
|
||
"downloads",
|
||
"install.json",
|
||
"install.stderr.log",
|
||
"install.stdout.log",
|
||
"grok-page.png",
|
||
"marketplace-cache",
|
||
}
|
||
candidates = [item for item in legacy.iterdir() if item.name not in excluded]
|
||
if not candidates:
|
||
return
|
||
self.migration_warnings.append(
|
||
"检测到旧版 Grok 运行状态,已隔离且不会加载:"
|
||
+ "、".join(str(item) for item in candidates)
|
||
)
|
||
|
||
def load_integration_settings(self) -> dict:
|
||
defaults = {
|
||
"binary_path": "",
|
||
"runtime_channel": "stable",
|
||
"default_workspace": str(self.project_dir),
|
||
"default_model": MODEL_PROFILE,
|
||
"context_window": 128000,
|
||
"sync_backend_model": True,
|
||
"sync_mcp_servers": False,
|
||
"customer_service_tools": True,
|
||
"external_compatibility": False,
|
||
"successful_probe_cache_ttl_sec": 300,
|
||
"chat_auto_approve": False,
|
||
}
|
||
sources = [self.integration_defaults_file, self.integration_settings_file]
|
||
for source in sources:
|
||
if source is None:
|
||
continue
|
||
try:
|
||
value = json.loads(source.read_text(encoding="utf-8"))
|
||
if isinstance(value, dict):
|
||
defaults.update(value)
|
||
except (OSError, ValueError, TypeError):
|
||
continue
|
||
return defaults
|
||
|
||
def save_integration_settings(self, values: Mapping[str, object]) -> dict:
|
||
settings = self.load_integration_settings()
|
||
allowed = {
|
||
"binary_path",
|
||
"runtime_channel",
|
||
"default_workspace",
|
||
"default_model",
|
||
"context_window",
|
||
"sync_backend_model",
|
||
"sync_mcp_servers",
|
||
"customer_service_tools",
|
||
"external_compatibility",
|
||
"successful_probe_cache_ttl_sec",
|
||
"chat_auto_approve",
|
||
}
|
||
settings.update({key: values[key] for key in allowed if key in values})
|
||
self.integration_settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||
self._atomic_write(
|
||
self.integration_settings_file,
|
||
json.dumps(settings, ensure_ascii=False, indent=2) + "\n",
|
||
)
|
||
return settings
|
||
|
||
def load_ai_settings(self) -> dict:
|
||
source = self.ai_settings_file
|
||
if (
|
||
self._uses_default_ai_settings
|
||
and not source.is_file()
|
||
and (self.project_dir / "ai_settings.json").is_file()
|
||
):
|
||
source = self.project_dir / "ai_settings.json"
|
||
try:
|
||
value = json.loads(source.read_text(encoding="utf-8"))
|
||
except FileNotFoundError as exc:
|
||
raise GrokBuildError(f"找不到模型配置:{source}") from exc
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
raise GrokBuildError(f"无法读取模型配置:{exc}") from exc
|
||
if not isinstance(value, dict):
|
||
raise GrokBuildError("AI 设置文件根节点必须是 JSON 对象")
|
||
return value
|
||
|
||
@staticmethod
|
||
def _atomic_write(path: Path, content: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_name(
|
||
f"{path.name}.{os.getpid()}.{threading.get_ident()}."
|
||
f"{uuid.uuid4().hex}.tmp"
|
||
)
|
||
try:
|
||
temporary.write_text(content, encoding="utf-8")
|
||
os.replace(temporary, path)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
|
||
@staticmethod
|
||
def _normalize_model_endpoint(api_base: str) -> tuple[bool, str, str, str]:
|
||
value = api_base.strip().rstrip("/")
|
||
if not value:
|
||
return False, "", "", "后台未配置 API 地址"
|
||
try:
|
||
parsed = urlsplit(value)
|
||
except ValueError:
|
||
return False, "", "", "API 地址格式无效"
|
||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||
return False, "", "", "API 地址必须是 http 或 https URL"
|
||
if parsed.username or parsed.password:
|
||
return False, "", "", "API 地址不能包含用户名或密码,请使用 API Key 字段"
|
||
hostname = (parsed.hostname or "").lower()
|
||
if (
|
||
hostname == "x.ai"
|
||
or hostname.endswith(".x.ai")
|
||
or hostname == "grok.com"
|
||
or hostname.endswith(".grok.com")
|
||
):
|
||
return (
|
||
False,
|
||
"",
|
||
"",
|
||
"Grok Agent 必须使用后台自有模型,不能配置 xAI/Grok 模型端点",
|
||
)
|
||
if parsed.query or parsed.fragment:
|
||
return (
|
||
False,
|
||
"",
|
||
"",
|
||
"当前 Grok Build 桥接不支持带 query 或 fragment 的模型地址",
|
||
)
|
||
|
||
path = parsed.path.rstrip("/")
|
||
lowered = path.lower()
|
||
if lowered.endswith("/chat-messages"):
|
||
path = path[: -len("/chat-messages")].rstrip("/")
|
||
normalized = urlunsplit(
|
||
(parsed.scheme, parsed.netloc, path, parsed.query, "")
|
||
).rstrip("/")
|
||
return True, normalized, "dify", "检测到 Dify /chat-messages 协议"
|
||
|
||
endpoints = (
|
||
("/chat/completions", "chat_completions"),
|
||
("/responses", "responses"),
|
||
("/messages", "messages"),
|
||
)
|
||
for suffix, backend in endpoints:
|
||
if lowered.endswith(suffix):
|
||
path = path[: -len(suffix)].rstrip("/")
|
||
normalized = urlunsplit(
|
||
(parsed.scheme, parsed.netloc, path, parsed.query, "")
|
||
).rstrip("/")
|
||
return True, normalized, backend, "协议已从完整端点自动识别"
|
||
|
||
normalized = urlunsplit(
|
||
(parsed.scheme, parsed.netloc, path, parsed.query, "")
|
||
).rstrip("/")
|
||
return (
|
||
True,
|
||
normalized,
|
||
"chat_completions",
|
||
"按 OpenAI Chat Completions 兼容地址配置",
|
||
)
|
||
|
||
def model_profile(self, ai_settings: Mapping[str, object] | None = None) -> ModelProfile:
|
||
settings = dict(ai_settings or self.load_ai_settings())
|
||
dedicated = bool(settings.get("GROK_MODEL_ENABLED", False))
|
||
api_base_key = "GROK_API_BASE" if dedicated else "AI_API_BASE"
|
||
model_key = "GROK_MODEL" if dedicated else "AI_MODEL"
|
||
temperature_key = "GROK_TEMPERATURE" if dedicated else "AI_TEMPERATURE"
|
||
max_tokens_key = "GROK_MAX_TOKENS" if dedicated else "AI_MAX_TOKENS"
|
||
compatible, base_url, backend, reason = self._normalize_model_endpoint(
|
||
str(settings.get(api_base_key) or "")
|
||
)
|
||
if dedicated:
|
||
configured_backend = str(
|
||
settings.get("GROK_API_BACKEND") or "chat_completions"
|
||
).strip()
|
||
if configured_backend in {
|
||
"chat_completions",
|
||
"responses",
|
||
"messages",
|
||
"dify",
|
||
}:
|
||
if compatible:
|
||
if backend == "dify" and configured_backend != "dify":
|
||
compatible = False
|
||
reason = (
|
||
"API 地址是 Dify /chat-messages;请把接口协议选择为 "
|
||
"Dify Chat Messages(本地工具调用适配)"
|
||
)
|
||
else:
|
||
backend = configured_backend
|
||
if backend == "dify":
|
||
reason = (
|
||
"Dify Chat Messages 将通过项目内置本地工具调用适配器"
|
||
"接入 Grok Build"
|
||
)
|
||
else:
|
||
compatible = False
|
||
reason = f"后台配置了不支持的 Agent 自有模型协议:{configured_backend}"
|
||
model = str(settings.get(model_key) or "").strip()
|
||
if compatible and backend == "dify" and not model:
|
||
model = "dify-app"
|
||
if compatible and not model:
|
||
compatible = False
|
||
reason = "后台未配置 Agent 自有模型名称" if dedicated else "后台未配置模型名称"
|
||
key_name = "GROK_API_KEY" if dedicated else "AI_API_KEY"
|
||
if compatible and not str(settings.get(key_name) or "").strip():
|
||
compatible = False
|
||
reason = (
|
||
"后台未配置 Agent 自有模型 API Key;"
|
||
"自定义模型必须提供独立密钥,系统不会使用 xAI 登录凭据"
|
||
)
|
||
configured_auth = str(
|
||
settings.get("GROK_AUTH_SCHEME") or "auto"
|
||
).strip().lower() if dedicated else "auto"
|
||
if configured_auth not in {"auto", "bearer", "x_api_key"}:
|
||
compatible = False
|
||
reason = f"后台配置了不支持的认证方式:{configured_auth}"
|
||
configured_auth = "bearer"
|
||
if configured_auth == "auto":
|
||
hostname = (urlsplit(base_url).hostname or "").lower() if base_url else ""
|
||
auth_scheme = (
|
||
"x_api_key"
|
||
if backend == "messages"
|
||
and (hostname == "api.anthropic.com" or hostname.endswith(".anthropic.com"))
|
||
else "bearer"
|
||
)
|
||
else:
|
||
auth_scheme = configured_auth
|
||
if backend == "dify":
|
||
if configured_auth == "x_api_key":
|
||
compatible = False
|
||
reason = "Dify Chat Messages 必须使用 Authorization Bearer 认证"
|
||
auth_scheme = "bearer"
|
||
try:
|
||
temperature = min(
|
||
2.0, max(0.0, float(settings.get(temperature_key, 0.7)))
|
||
)
|
||
except (TypeError, ValueError):
|
||
temperature = 0.7
|
||
try:
|
||
max_tokens = min(
|
||
262144, max(64, int(settings.get(max_tokens_key, 8192)))
|
||
)
|
||
except (TypeError, ValueError):
|
||
max_tokens = 8192
|
||
integration = self.load_integration_settings()
|
||
try:
|
||
context_window = min(
|
||
2_000_000,
|
||
max(
|
||
4096,
|
||
int(
|
||
settings.get("GROK_CONTEXT_WINDOW", 128000)
|
||
if dedicated
|
||
else integration.get("context_window", 128000)
|
||
),
|
||
),
|
||
)
|
||
except (TypeError, ValueError):
|
||
context_window = 128000
|
||
return ModelProfile(
|
||
compatible=compatible,
|
||
profile=MODEL_PROFILE,
|
||
model=model,
|
||
base_url=base_url,
|
||
api_backend=backend,
|
||
auth_scheme=auth_scheme,
|
||
temperature=temperature,
|
||
max_completion_tokens=max_tokens,
|
||
context_window=context_window,
|
||
reason=reason,
|
||
)
|
||
|
||
def _agent_uses_dify_source(self) -> bool:
|
||
"""Return whether the managed Agent model is configured through Dify.
|
||
|
||
Grok Build's built-in ``web_search`` helper is not an ordinary function
|
||
tool: it always calls a model endpoint that implements the Responses API
|
||
and native web search. The loopback Dify adapter intentionally exposes
|
||
Chat Completions only, so advertising that helper would make every web
|
||
search call ``/v1/responses`` and fail with HTTP 404.
|
||
"""
|
||
try:
|
||
settings = self.load_ai_settings()
|
||
except GrokBuildError:
|
||
return False
|
||
return bool(
|
||
settings.get("GROK_MODEL_ENABLED", False)
|
||
and str(settings.get("GROK_API_BACKEND") or "").strip().lower()
|
||
== "dify"
|
||
)
|
||
|
||
def agent_model_profile(
|
||
self,
|
||
ai_settings: Mapping[str, object] | None = None,
|
||
) -> ModelProfile:
|
||
"""Return the dedicated model allowed to power Grok Build Agent.
|
||
|
||
The normal customer-service ``AI_*`` endpoint is intentionally not a
|
||
fallback. A configured Dify application is exposed to Grok through
|
||
the loopback-only protocol adapter, while native model endpoints pass
|
||
through unchanged.
|
||
"""
|
||
settings = dict(ai_settings or self.load_ai_settings())
|
||
if bool(settings.get("GROK_MODEL_ENABLED", False)):
|
||
profile = self.model_profile(settings)
|
||
if not profile.compatible or profile.api_backend != "dify":
|
||
return profile
|
||
try:
|
||
from dify_grok_adapter import ensure_dify_adapter
|
||
|
||
try:
|
||
adapter_timeout = int(
|
||
settings.get("GROK_CUSTOMER_SERVICE_TIMEOUT", 180)
|
||
)
|
||
except (TypeError, ValueError):
|
||
adapter_timeout = 180
|
||
adapter = ensure_dify_adapter(
|
||
str(self.runtime_home),
|
||
upstream_base_url=profile.base_url,
|
||
api_key=str(settings.get("GROK_API_KEY") or ""),
|
||
model=profile.model,
|
||
timeout=adapter_timeout,
|
||
inputs=(
|
||
settings.get("GROK_DIFY_INPUTS")
|
||
if isinstance(
|
||
settings.get("GROK_DIFY_INPUTS"),
|
||
Mapping,
|
||
)
|
||
else {}
|
||
),
|
||
)
|
||
except Exception as exc:
|
||
return replace(
|
||
profile,
|
||
compatible=False,
|
||
reason=f"无法启动 Dify 本地工具调用适配器:{exc}",
|
||
source_backend="dify",
|
||
source_base_url=profile.base_url,
|
||
)
|
||
return replace(
|
||
profile,
|
||
base_url=adapter.base_url,
|
||
api_backend="chat_completions",
|
||
auth_scheme="bearer",
|
||
reason=(
|
||
"Dify Chat Messages 已通过项目内置本地工具调用适配器接入"
|
||
),
|
||
source_backend="dify",
|
||
source_base_url=profile.base_url,
|
||
adapter_instance_id=adapter.instance_id,
|
||
)
|
||
return ModelProfile(
|
||
compatible=False,
|
||
profile=MODEL_PROFILE,
|
||
model="",
|
||
base_url="",
|
||
api_backend="",
|
||
auth_scheme="bearer",
|
||
temperature=0.3,
|
||
max_completion_tokens=8192,
|
||
context_window=128000,
|
||
reason=(
|
||
"后台尚未启用 Grok Agent 自有模型;请配置 GROK_API_BASE、"
|
||
"GROK_API_KEY 和 GROK_MODEL。Agent 不会回退到 Grok/xAI 模型"
|
||
),
|
||
)
|
||
|
||
def agent_model_api_key(
|
||
self,
|
||
ai_settings: Mapping[str, object] | None = None,
|
||
*,
|
||
profile: ModelProfile | None = None,
|
||
) -> str:
|
||
"""Return the credential Grok may receive for the effective endpoint.
|
||
|
||
Native providers use their configured key. Dify uses a random
|
||
loopback-adapter token so the Dify application key never enters the
|
||
Grok child process.
|
||
"""
|
||
settings = dict(ai_settings or self.load_ai_settings())
|
||
effective = profile or self.agent_model_profile(settings)
|
||
if not effective.compatible:
|
||
raise GrokBuildError(effective.reason)
|
||
if effective.source_backend != "dify":
|
||
key = str(settings.get("GROK_API_KEY") or "").strip()
|
||
if not key:
|
||
raise GrokBuildError("后台 Agent 自有模型缺少独立 API Key")
|
||
return key
|
||
try:
|
||
from dify_grok_adapter import ensure_dify_adapter
|
||
|
||
try:
|
||
adapter_timeout = int(
|
||
settings.get("GROK_CUSTOMER_SERVICE_TIMEOUT", 180)
|
||
)
|
||
except (TypeError, ValueError):
|
||
adapter_timeout = 180
|
||
adapter = ensure_dify_adapter(
|
||
str(self.runtime_home),
|
||
upstream_base_url=effective.source_base_url,
|
||
api_key=str(settings.get("GROK_API_KEY") or ""),
|
||
model=effective.model,
|
||
timeout=adapter_timeout,
|
||
inputs=(
|
||
settings.get("GROK_DIFY_INPUTS")
|
||
if isinstance(
|
||
settings.get("GROK_DIFY_INPUTS"),
|
||
Mapping,
|
||
)
|
||
else {}
|
||
),
|
||
)
|
||
except Exception as exc:
|
||
raise GrokBuildError(
|
||
f"无法取得 Dify 本地适配器凭据:{exc}"
|
||
) from exc
|
||
if adapter.base_url != effective.base_url:
|
||
raise GrokBuildError(
|
||
"Dify 本地适配器端口在配置核验期间发生变化,请重新同步"
|
||
)
|
||
return adapter.local_api_key
|
||
|
||
@staticmethod
|
||
def _model_operation_endpoint(profile: ModelProfile) -> str:
|
||
suffixes = {
|
||
"chat_completions": "chat/completions",
|
||
"responses": "responses",
|
||
"messages": "messages",
|
||
}
|
||
suffix = suffixes.get(profile.api_backend, "")
|
||
if not suffix or not profile.base_url:
|
||
return ""
|
||
return f"{profile.base_url.rstrip('/')}/{suffix}"
|
||
|
||
@staticmethod
|
||
def _model_probe_payload(profile: ModelProfile) -> dict[str, object]:
|
||
if profile.source_backend == "dify":
|
||
challenge = uuid.uuid4().hex
|
||
return {
|
||
"model": profile.model,
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": (
|
||
"调用 health_check 工具完成协议预检,token 必须为 "
|
||
f"{challenge}。"
|
||
),
|
||
}
|
||
],
|
||
"tools": [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "health_check",
|
||
"description": "完成本地协议预检;不执行外部操作。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"token": {
|
||
"type": "string",
|
||
"const": challenge,
|
||
}
|
||
},
|
||
"required": ["token"],
|
||
"additionalProperties": False,
|
||
},
|
||
},
|
||
}
|
||
],
|
||
"tool_choice": {
|
||
"type": "function",
|
||
"function": {"name": "health_check"},
|
||
},
|
||
"max_tokens": 32,
|
||
"stream": True,
|
||
}
|
||
if profile.api_backend == "responses":
|
||
return {
|
||
"model": profile.model,
|
||
"input": "health check",
|
||
"max_output_tokens": 8,
|
||
"stream": True,
|
||
}
|
||
if profile.api_backend == "messages":
|
||
return {
|
||
"model": profile.model,
|
||
"messages": [{"role": "user", "content": "health check"}],
|
||
"max_tokens": 8,
|
||
"stream": True,
|
||
}
|
||
return {
|
||
"model": profile.model,
|
||
"messages": [{"role": "user", "content": "health check"}],
|
||
"max_tokens": 8,
|
||
"stream": True,
|
||
}
|
||
|
||
@staticmethod
|
||
def _unauthenticated_route_status(
|
||
endpoint: str,
|
||
*,
|
||
timeout: float,
|
||
) -> int | None:
|
||
"""Return only a route status; never attach a model credential."""
|
||
request = urllib.request.Request(
|
||
endpoint,
|
||
data=b"{}",
|
||
headers={
|
||
"User-Agent": USER_AGENT,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
return int(response.getcode())
|
||
except urllib.error.HTTPError as exc:
|
||
try:
|
||
return int(exc.code)
|
||
finally:
|
||
exc.close()
|
||
except (OSError, urllib.error.URLError, ValueError):
|
||
return None
|
||
|
||
def _detect_endpoint_protocol(
|
||
self,
|
||
profile: ModelProfile,
|
||
*,
|
||
timeout: float,
|
||
) -> tuple[str, str]:
|
||
"""Detect a nearby route after a 404 without sending the API key."""
|
||
base = profile.base_url.rstrip("/")
|
||
route_timeout = min(3.0, max(1.0, timeout / 3.0))
|
||
labels = {
|
||
"chat_completions": "OpenAI Chat Completions",
|
||
"responses": "OpenAI Responses",
|
||
"messages": "Anthropic Messages",
|
||
}
|
||
suffixes = {
|
||
"chat_completions": "chat/completions",
|
||
"responses": "responses",
|
||
"messages": "messages",
|
||
}
|
||
recognized_statuses = {200, 201, 400, 401, 403, 422, 429}
|
||
for backend in ("responses", "chat_completions", "messages"):
|
||
if backend == profile.api_backend:
|
||
continue
|
||
endpoint = f"{base}/{suffixes[backend]}"
|
||
status = self._unauthenticated_route_status(
|
||
endpoint,
|
||
timeout=route_timeout,
|
||
)
|
||
if status in recognized_statuses:
|
||
return backend, labels[backend]
|
||
|
||
dify_endpoint = f"{base}/chat-messages"
|
||
dify_status = self._unauthenticated_route_status(
|
||
dify_endpoint,
|
||
timeout=route_timeout,
|
||
)
|
||
if dify_status in recognized_statuses:
|
||
return "dify_chat_messages", "Dify /chat-messages"
|
||
return "", ""
|
||
|
||
@staticmethod
|
||
def _probe_failure_message(
|
||
profile: ModelProfile,
|
||
endpoint: str,
|
||
status: int,
|
||
detected_protocol: str,
|
||
detected_label: str,
|
||
) -> str:
|
||
protocol_labels = {
|
||
"chat_completions": "OpenAI Chat Completions",
|
||
"responses": "OpenAI Responses",
|
||
"messages": "Anthropic Messages",
|
||
}
|
||
selected = (
|
||
"Dify Chat Messages 本地适配器"
|
||
if profile.source_backend == "dify"
|
||
else protocol_labels.get(profile.api_backend, profile.api_backend)
|
||
)
|
||
if status == 404 and detected_protocol == "dify_chat_messages":
|
||
return (
|
||
f"{selected} 端点返回 HTTP 404:{endpoint}。检测到同一服务实际"
|
||
"提供 Dify /chat-messages;请在管理后台把接口协议改为 "
|
||
"Dify Chat Messages(本地工具调用适配)。桌面端会自动创建"
|
||
"本机适配端点并验证工具调用协议。"
|
||
)
|
||
if status == 404 and detected_protocol:
|
||
return (
|
||
f"{selected} 端点返回 HTTP 404:{endpoint}。同一基础地址检测到"
|
||
f" {detected_label},请在管理后台选择匹配的接口协议。"
|
||
)
|
||
if status == 404:
|
||
return (
|
||
f"{selected} 端点或模型不存在(HTTP 404):{endpoint}。"
|
||
"请核对 API 基址、接口协议和模型名称。"
|
||
)
|
||
if status in {401, 403}:
|
||
return (
|
||
f"{selected} 端点认证失败(HTTP {status})。请核对自有模型 "
|
||
"API Key 和认证方式。"
|
||
)
|
||
if status == 429:
|
||
return (
|
||
f"{selected} 端点当前限流或额度不足(HTTP 429),暂不能启动 "
|
||
"Agent。"
|
||
)
|
||
if status in {400, 405, 415, 422}:
|
||
return (
|
||
f"{selected} 端点拒绝了 Grok Build 兼容请求(HTTP {status})。"
|
||
"请确认服务实现了所选协议及流式生成。"
|
||
)
|
||
return f"{selected} 端点预检失败(HTTP {status}):{endpoint}"
|
||
|
||
def probe_agent_model(
|
||
self,
|
||
ai_settings: Mapping[str, object] | None = None,
|
||
*,
|
||
timeout: float = 12.0,
|
||
force: bool = False,
|
||
cache_ttl: float = 30.0,
|
||
) -> ModelEndpointProbe:
|
||
"""Verify the exact model route Grok Build will call.
|
||
|
||
This performs a tiny streaming request with the configured custom-model
|
||
credential. The result and all errors are deliberately secret-free.
|
||
"""
|
||
settings = dict(ai_settings or self.load_ai_settings())
|
||
profile = self.agent_model_profile(settings)
|
||
endpoint = self._model_operation_endpoint(profile)
|
||
reported_backend = profile.source_backend or profile.api_backend
|
||
if not profile.compatible or not endpoint:
|
||
return ModelEndpointProbe(
|
||
ok=False,
|
||
checked=False,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=None,
|
||
latency_ms=0,
|
||
message=profile.reason or "后台自有模型配置不完整",
|
||
)
|
||
|
||
upstream_api_key = str(settings.get("GROK_API_KEY") or "")
|
||
api_key = self.agent_model_api_key(settings, profile=profile)
|
||
key_digest = hashlib.sha256(
|
||
upstream_api_key.encode("utf-8")
|
||
).hexdigest()
|
||
cache_key = hashlib.sha256(
|
||
json.dumps(
|
||
{
|
||
"base_url": profile.base_url,
|
||
"api_backend": profile.api_backend,
|
||
"source_base_url": profile.source_base_url,
|
||
"source_backend": profile.source_backend,
|
||
"adapter_instance_id": profile.adapter_instance_id,
|
||
"auth_scheme": profile.auth_scheme,
|
||
"model": profile.model,
|
||
"key_digest": key_digest,
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
).encode("utf-8")
|
||
).hexdigest()
|
||
|
||
with self._model_probe_lock:
|
||
now = time.monotonic()
|
||
with _MODEL_PROBE_STATE_GUARD:
|
||
cached = _MODEL_PROBE_CACHE.get(self._model_probe_scope)
|
||
requested_ttl = max(0.0, float(cache_ttl))
|
||
effective_ttl = requested_ttl
|
||
if cached is not None and cached[2].ok and requested_ttl > 0:
|
||
integration = self.load_integration_settings()
|
||
try:
|
||
successful_ttl = float(
|
||
integration.get(
|
||
"successful_probe_cache_ttl_sec",
|
||
300,
|
||
)
|
||
)
|
||
except (TypeError, ValueError):
|
||
successful_ttl = 300.0
|
||
effective_ttl = max(
|
||
requested_ttl,
|
||
min(3600.0, max(300.0, successful_ttl)),
|
||
)
|
||
if (
|
||
not force
|
||
and cached is not None
|
||
and cached[0] == cache_key
|
||
and now - cached[1] <= effective_ttl
|
||
):
|
||
return cached[2]
|
||
|
||
headers = {
|
||
"User-Agent": USER_AGENT,
|
||
"Content-Type": "application/json",
|
||
"Accept": "text/event-stream, application/json",
|
||
}
|
||
if profile.auth_scheme == "x_api_key":
|
||
headers["x-api-key"] = api_key
|
||
headers["anthropic-version"] = "2023-06-01"
|
||
else:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
request = urllib.request.Request(
|
||
endpoint,
|
||
data=json.dumps(
|
||
self._model_probe_payload(profile),
|
||
ensure_ascii=False,
|
||
).encode("utf-8"),
|
||
headers=headers,
|
||
method="POST",
|
||
)
|
||
started = time.monotonic()
|
||
try:
|
||
with urllib.request.urlopen(
|
||
request,
|
||
timeout=max(1.0, float(timeout)),
|
||
) as response:
|
||
status = int(response.getcode())
|
||
content_type = str(
|
||
response.headers.get("Content-Type", "")
|
||
).lower()
|
||
latency_ms = max(0, int((time.monotonic() - started) * 1000))
|
||
if 200 <= status < 300 and "text/event-stream" in content_type:
|
||
insecure_dify = bool(
|
||
profile.source_backend == "dify"
|
||
and profile.source_base_url.lower().startswith("http://")
|
||
and not re.match(
|
||
r"^http://(?:127\.0\.0\.1|localhost|\[::1\])(?::|/|$)",
|
||
profile.source_base_url,
|
||
flags=re.I,
|
||
)
|
||
)
|
||
result = ModelEndpointProbe(
|
||
ok=True,
|
||
checked=True,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=status,
|
||
latency_ms=latency_ms,
|
||
message=(
|
||
(
|
||
"警告:Dify 上游使用明文 HTTP,API Key 与会话"
|
||
"内容未加密传输。请尽快改用 HTTPS。"
|
||
if insecure_dify
|
||
else ""
|
||
)
|
||
+ (
|
||
"Dify 本地工具调用适配器预检通过"
|
||
if profile.source_backend == "dify"
|
||
else "模型端点预检通过"
|
||
)
|
||
+ f"(HTTP {status},{latency_ms}ms):"
|
||
+ (
|
||
profile.source_base_url
|
||
if profile.source_backend == "dify"
|
||
else endpoint
|
||
)
|
||
),
|
||
)
|
||
elif 200 <= status < 300:
|
||
result = ModelEndpointProbe(
|
||
ok=False,
|
||
checked=True,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=status,
|
||
latency_ms=latency_ms,
|
||
message=(
|
||
f"模型端点返回 HTTP {status},但没有提供 Grok Build "
|
||
"所需的 text/event-stream 流式响应。请核对接口协议。"
|
||
),
|
||
)
|
||
else:
|
||
result = ModelEndpointProbe(
|
||
ok=False,
|
||
checked=True,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=status,
|
||
latency_ms=latency_ms,
|
||
message=self._probe_failure_message(
|
||
profile,
|
||
endpoint,
|
||
status,
|
||
"",
|
||
"",
|
||
),
|
||
)
|
||
except urllib.error.HTTPError as exc:
|
||
status = int(exc.code)
|
||
exc.close()
|
||
latency_ms = max(0, int((time.monotonic() - started) * 1000))
|
||
detected_protocol = ""
|
||
detected_label = ""
|
||
if status == 404:
|
||
detected_protocol, detected_label = (
|
||
self._detect_endpoint_protocol(
|
||
profile,
|
||
timeout=max(1.0, float(timeout)),
|
||
)
|
||
)
|
||
result = ModelEndpointProbe(
|
||
ok=False,
|
||
checked=True,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=status,
|
||
latency_ms=latency_ms,
|
||
message=self._probe_failure_message(
|
||
profile,
|
||
endpoint,
|
||
status,
|
||
detected_protocol,
|
||
detected_label,
|
||
),
|
||
detected_protocol=detected_protocol,
|
||
)
|
||
except (OSError, urllib.error.URLError, ValueError) as exc:
|
||
latency_ms = max(0, int((time.monotonic() - started) * 1000))
|
||
reason = str(getattr(exc, "reason", exc) or "连接失败")
|
||
result = ModelEndpointProbe(
|
||
ok=False,
|
||
checked=True,
|
||
api_backend=reported_backend,
|
||
endpoint=endpoint,
|
||
http_status=None,
|
||
latency_ms=latency_ms,
|
||
message=(
|
||
f"无法连接后台自有模型端点:{endpoint}({reason})。"
|
||
"请检查服务器、网络和端口。"
|
||
),
|
||
)
|
||
|
||
with _MODEL_PROBE_STATE_GUARD:
|
||
_MODEL_PROBE_CACHE[self._model_probe_scope] = (
|
||
cache_key,
|
||
time.monotonic(),
|
||
result,
|
||
)
|
||
return result
|
||
|
||
def _render_managed_config(
|
||
self,
|
||
profile: ModelProfile,
|
||
ai_settings: Mapping[str, object],
|
||
include_mcp: bool,
|
||
include_customer_service_tools: bool = False,
|
||
subagents_enabled: bool = True,
|
||
external_compatibility: bool = False,
|
||
disabled_plugins: Sequence[str] = (),
|
||
disabled_external_mcp_names: Sequence[str] = (),
|
||
) -> str:
|
||
if profile.compatible and profile.api_backend == "dify":
|
||
raise GrokBuildError(
|
||
"Dify 来源协议不能直接写入 Grok 配置,必须先解析本地适配端点"
|
||
)
|
||
if profile.compatible and profile.source_backend == "dify":
|
||
parsed_adapter = urlsplit(profile.base_url)
|
||
if (
|
||
parsed_adapter.hostname not in {"127.0.0.1", "localhost", "::1"}
|
||
or profile.api_backend != "chat_completions"
|
||
):
|
||
raise GrokBuildError(
|
||
"Dify 的 Grok 有效模型必须指向本机 Chat Completions 适配器"
|
||
)
|
||
lines = [
|
||
MANAGED_CONFIG_BEGIN,
|
||
"# 由企业微信 RPA 根据后台配置自动生成。",
|
||
"# API Key 不写入此文件,由 WECOM_GROK_API_KEY 环境变量注入。",
|
||
"",
|
||
"[compat.cursor]",
|
||
*[
|
||
f"{surface} = {'true' if external_compatibility else 'false'}"
|
||
for surface in (
|
||
"skills",
|
||
"rules",
|
||
"agents",
|
||
"mcps",
|
||
"hooks",
|
||
"sessions",
|
||
)
|
||
],
|
||
"",
|
||
"[compat.claude]",
|
||
*[
|
||
f"{surface} = {'true' if external_compatibility else 'false'}"
|
||
for surface in (
|
||
"skills",
|
||
"rules",
|
||
"agents",
|
||
"mcps",
|
||
"hooks",
|
||
"sessions",
|
||
)
|
||
],
|
||
"",
|
||
"[compat.codex]",
|
||
f"sessions = {'true' if external_compatibility else 'false'}",
|
||
"",
|
||
"[plugins]",
|
||
f"disabled = {self._toml_value(sorted(set(disabled_plugins)))}",
|
||
]
|
||
if not external_compatibility:
|
||
# A native permission table prevents Grok from falling back to
|
||
# ~/.claude/settings*.json, whose rules are otherwise loaded even
|
||
# when the regular Claude compatibility cells are disabled.
|
||
lines.extend(["", "[permission]", "rules = []"])
|
||
if profile.compatible:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"[models]",
|
||
f"default = {_toml_string(profile.profile)}",
|
||
f"allowed_models = [{_toml_string(profile.profile)}]",
|
||
f"web_search = {_toml_string(profile.profile)}",
|
||
f"session_summary = {_toml_string(profile.profile)}",
|
||
f"image_description = {_toml_string(profile.profile)}",
|
||
f"prompt_suggestion = {_toml_string(profile.profile)}",
|
||
"",
|
||
"[ui]",
|
||
"prompt_suggestions = false",
|
||
f"fork_secondary_model = {_toml_string(profile.profile)}",
|
||
"",
|
||
"[suggestions]",
|
||
"enabled = false",
|
||
"ai_enabled = false",
|
||
f"ai_model = {_toml_string(profile.profile)}",
|
||
"",
|
||
"[subagents]",
|
||
f"enabled = {'true' if subagents_enabled else 'false'}",
|
||
"",
|
||
"[subagents.models]",
|
||
*[
|
||
f"{self._toml_key(name)} = {_toml_string(profile.profile)}"
|
||
for name in sorted(PINNED_SUBAGENT_NAMES)
|
||
],
|
||
"",
|
||
"[goal]",
|
||
"use_current_model_only = true",
|
||
"",
|
||
"[auto_mode]",
|
||
f"classifier_model = {_toml_string(profile.profile)}",
|
||
"",
|
||
"[compaction.memory_flush]",
|
||
f"flush_model = {_toml_string(profile.profile)}",
|
||
"",
|
||
f"[model.{profile.profile}]",
|
||
f"model = {_toml_string(profile.model)}",
|
||
f"base_url = {_toml_string(profile.base_url)}",
|
||
f"name = {_toml_string('后台模型 · ' + profile.model)}",
|
||
f"env_key = {_toml_string(MODEL_API_KEY_ENV)}",
|
||
f"api_backend = {_toml_string(profile.api_backend)}",
|
||
f"temperature = {profile.temperature:.6g}",
|
||
f"max_completion_tokens = {profile.max_completion_tokens}",
|
||
f"context_window = {profile.context_window}",
|
||
]
|
||
)
|
||
if profile.source_backend != "dify":
|
||
lines.append(
|
||
f"auth_scheme = {_toml_string(profile.auth_scheme)}"
|
||
)
|
||
if profile.api_backend == "messages" and profile.auth_scheme == "x_api_key":
|
||
lines.extend(
|
||
[
|
||
'extra_headers = { "anthropic-version" = "2023-06-01" }',
|
||
]
|
||
)
|
||
managed_mcp_servers: list[object] = []
|
||
if include_customer_service_tools:
|
||
managed_mcp_servers.extend(self._customer_service_mcp_servers(ai_settings))
|
||
if include_mcp and isinstance(ai_settings.get("AI_MCP_SERVERS"), list):
|
||
managed_mcp_servers.extend(ai_settings["AI_MCP_SERVERS"])
|
||
managed_mcp_names: set[str] = set()
|
||
if managed_mcp_servers:
|
||
managed_mcp_lines = self._render_mcp_servers(managed_mcp_servers)
|
||
lines.extend(managed_mcp_lines)
|
||
managed_mcp_names = self._configured_mcp_names(
|
||
"\n".join(managed_mcp_lines)
|
||
)
|
||
for name in sorted(
|
||
{
|
||
str(value).strip()
|
||
for value in disabled_external_mcp_names
|
||
if str(value).strip()
|
||
}
|
||
- managed_mcp_names
|
||
):
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"[mcp_servers.{self._toml_key(name)}]",
|
||
"enabled = false",
|
||
]
|
||
)
|
||
lines.extend(["", MANAGED_CONFIG_END, ""])
|
||
return "\n".join(lines)
|
||
|
||
def _customer_service_mcp_servers(
|
||
self,
|
||
ai_settings: Mapping[str, object] | None = None,
|
||
) -> list[dict[str, object]]:
|
||
"""Return the trusted project-local MCP adapter exposed to Grok Build.
|
||
|
||
Credentials are deliberately absent from the TOML. The server exposes
|
||
only deterministic, session-scoped context, intent, validation, and
|
||
pending-registration operations; Grok itself generates the reply and
|
||
the MCP can never send a message to WeCom.
|
||
"""
|
||
settings = dict(ai_settings or {})
|
||
try:
|
||
timeout = int(
|
||
settings.get("GROK_CUSTOMER_SERVICE_TIMEOUT", 180) or 180
|
||
)
|
||
except (TypeError, ValueError):
|
||
timeout = 180
|
||
return [
|
||
{
|
||
"name": "customer-service",
|
||
"transport": "stdio",
|
||
"command": sys.executable,
|
||
"args": [str(self.project_dir / "grok_customer_service_mcp.py")],
|
||
"cwd": str(self.project_dir),
|
||
"startup_timeout_sec": 30,
|
||
"tool_timeout_sec": min(900, max(30, timeout + 30)),
|
||
}
|
||
]
|
||
|
||
@staticmethod
|
||
def _safe_toml_identifier(value: str) -> str:
|
||
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-")
|
||
return cleaned or "server"
|
||
|
||
@staticmethod
|
||
def _mcp_value_env_name(server: str, category: str, key: object) -> str:
|
||
identity = f"{server}\0{category}\0{key}".encode("utf-8")
|
||
digest = hashlib.sha256(identity).hexdigest()[:16].upper()
|
||
return f"WECOM_GROK_MCP_{digest}"
|
||
|
||
def _mcp_config_value(
|
||
self,
|
||
server: str,
|
||
category: str,
|
||
key: object,
|
||
value: object,
|
||
) -> str:
|
||
rendered = str(value)
|
||
if re.fullmatch(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}", rendered):
|
||
variable = rendered[2:-1]
|
||
if (
|
||
variable == MODEL_API_KEY_ENV
|
||
or variable.startswith("WECOM_GROK_MCP_")
|
||
):
|
||
raise GrokBuildError(
|
||
f"{variable} 是桥接器保留的凭据变量,不能手工引用"
|
||
)
|
||
return rendered
|
||
if rendered.startswith("${") and rendered.endswith("}"):
|
||
raise GrokBuildError(
|
||
"MCP 环境变量引用仅支持 ${VAR},不支持默认值或嵌套表达式"
|
||
)
|
||
env_name = self._mcp_value_env_name(server, category, key)
|
||
return f"${{{env_name}}}"
|
||
|
||
def _render_mcp_servers(self, raw_servers: object) -> list[str]:
|
||
if not isinstance(raw_servers, list):
|
||
return []
|
||
output: list[str] = []
|
||
used: set[str] = set()
|
||
for index, raw in enumerate(raw_servers, start=1):
|
||
if not isinstance(raw, dict) or raw.get("enabled") is False:
|
||
continue
|
||
base_name = self._safe_toml_identifier(
|
||
str(raw.get("name") or raw.get("id") or f"server-{index}")
|
||
)
|
||
base_name = f"{MANAGED_MCP_PREFIX}{base_name}"
|
||
name = base_name
|
||
suffix = 2
|
||
while name in used:
|
||
name = f"{base_name}-{suffix}"
|
||
suffix += 1
|
||
used.add(name)
|
||
transport = str(raw.get("transport") or "").strip().lower()
|
||
command = str(raw.get("command") or "").strip()
|
||
url = str(raw.get("url") or "").strip()
|
||
if not transport:
|
||
transport = "stdio" if command else "http"
|
||
if transport == "stdio" and not command:
|
||
continue
|
||
if transport not in {"stdio", "sse", "http", "streamable_http"}:
|
||
continue
|
||
if transport != "stdio" and not url:
|
||
continue
|
||
secret_option = re.compile(
|
||
r"(?i)(?:^|[\s=])--?(?:api[-_]?key|access[-_]?token|token|"
|
||
r"secret|password|credential)(?:=|\s|$)"
|
||
)
|
||
if transport == "stdio" and secret_option.search(command):
|
||
raise GrokBuildError(
|
||
f"MCP {base_name} 的 command 含疑似密钥参数;请移到 env"
|
||
)
|
||
if transport != "stdio":
|
||
try:
|
||
parsed_url = urlsplit(url)
|
||
except ValueError as exc:
|
||
raise GrokBuildError(
|
||
f"MCP {base_name} 的 URL 无效"
|
||
) from exc
|
||
if (
|
||
parsed_url.scheme not in {"http", "https"}
|
||
or not parsed_url.netloc
|
||
):
|
||
raise GrokBuildError(f"MCP {base_name} 的 URL 无效")
|
||
if parsed_url.fragment:
|
||
raise GrokBuildError(f"MCP {base_name} 的 URL 不能包含 fragment")
|
||
if parsed_url.username or parsed_url.password:
|
||
raise GrokBuildError(
|
||
f"MCP {base_name} 的 URL 不能内嵌用户名或密码"
|
||
)
|
||
sensitive_query_names = {
|
||
"api_key",
|
||
"apikey",
|
||
"access_token",
|
||
"authorization",
|
||
"auth",
|
||
"bearer",
|
||
"key",
|
||
"token",
|
||
"secret",
|
||
"password",
|
||
"credential",
|
||
"signature",
|
||
"sig",
|
||
}
|
||
if any(
|
||
key.strip().lower().replace("-", "_")
|
||
in sensitive_query_names
|
||
for key, _value in parse_qsl(
|
||
parsed_url.query,
|
||
keep_blank_values=True,
|
||
)
|
||
):
|
||
raise GrokBuildError(
|
||
f"MCP {base_name} 的 URL query 含疑似密钥;请移到 headers"
|
||
)
|
||
output.extend(["", f"[mcp_servers.{name}]"])
|
||
if transport == "stdio":
|
||
output.append(f"command = {_toml_string(command)}")
|
||
args = raw.get("args")
|
||
if isinstance(args, list):
|
||
if any(secret_option.search(str(item)) for item in args):
|
||
raise GrokBuildError(
|
||
f"MCP {base_name} 的 args 含疑似密钥参数;请移到 env"
|
||
)
|
||
encoded = ", ".join(_toml_string(item) for item in args)
|
||
output.append(f"args = [{encoded}]")
|
||
env = raw.get("env")
|
||
if isinstance(env, dict) and env:
|
||
entries = ", ".join(
|
||
f"{_toml_string(key)} = "
|
||
f"{_toml_string(self._mcp_config_value(name, 'env', key, value))}"
|
||
for key, value in env.items()
|
||
)
|
||
output.append(f"env = {{ {entries} }}")
|
||
cwd = str(raw.get("cwd") or "").strip()
|
||
if cwd:
|
||
output.append(f"cwd = {_toml_string(cwd)}")
|
||
else:
|
||
output.append(f"url = {_toml_string(url)}")
|
||
if transport == "sse":
|
||
output.append('type = "sse"')
|
||
headers = raw.get("headers")
|
||
if isinstance(headers, dict) and headers:
|
||
entries = ", ".join(
|
||
f"{_toml_string(key)} = "
|
||
f"{_toml_string(self._mcp_config_value(name, 'header', key, value))}"
|
||
for key, value in headers.items()
|
||
)
|
||
output.append(f"headers = {{ {entries} }}")
|
||
numeric_fields = (
|
||
"startup_timeout_sec",
|
||
"tool_timeout_sec",
|
||
)
|
||
for field in numeric_fields:
|
||
try:
|
||
number = int(raw.get(field))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if number > 0:
|
||
output.append(f"{field} = {number}")
|
||
tool_timeouts = raw.get("tool_timeouts")
|
||
if isinstance(tool_timeouts, dict) and tool_timeouts:
|
||
entries: list[str] = []
|
||
for key, value in tool_timeouts.items():
|
||
try:
|
||
timeout = int(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if timeout > 0:
|
||
entries.append(f"{_toml_string(key)} = {timeout}")
|
||
if entries:
|
||
output.append(f"tool_timeouts = {{ {', '.join(entries)} }}")
|
||
return output
|
||
|
||
@staticmethod
|
||
def _parse_toml_key_path(raw: str) -> tuple[str, ...] | None:
|
||
"""Parse bare/basic/literal TOML dotted keys used by table headers."""
|
||
parts: list[str] = []
|
||
cursor = 0
|
||
length = len(raw)
|
||
escapes = {
|
||
"b": "\b",
|
||
"t": "\t",
|
||
"n": "\n",
|
||
"f": "\f",
|
||
"r": "\r",
|
||
'"': '"',
|
||
"\\": "\\",
|
||
}
|
||
while True:
|
||
while cursor < length and raw[cursor].isspace():
|
||
cursor += 1
|
||
if cursor >= length:
|
||
return tuple(parts) if parts else None
|
||
if raw[cursor] == '"':
|
||
cursor += 1
|
||
value: list[str] = []
|
||
closed = False
|
||
while cursor < length:
|
||
character = raw[cursor]
|
||
if character == '"':
|
||
cursor += 1
|
||
closed = True
|
||
break
|
||
if character != "\\":
|
||
value.append(character)
|
||
cursor += 1
|
||
continue
|
||
cursor += 1
|
||
if cursor >= length:
|
||
return None
|
||
escape = raw[cursor]
|
||
if escape in escapes:
|
||
value.append(escapes[escape])
|
||
cursor += 1
|
||
continue
|
||
if escape in {"u", "U"}:
|
||
digits = 4 if escape == "u" else 8
|
||
encoded = raw[cursor + 1 : cursor + 1 + digits]
|
||
if (
|
||
len(encoded) != digits
|
||
or not re.fullmatch(r"[0-9A-Fa-f]+", encoded)
|
||
):
|
||
return None
|
||
value.append(chr(int(encoded, 16)))
|
||
cursor += 1 + digits
|
||
continue
|
||
return None
|
||
if not closed:
|
||
return None
|
||
part = "".join(value)
|
||
elif raw[cursor] == "'":
|
||
end = raw.find("'", cursor + 1)
|
||
if end < 0:
|
||
return None
|
||
part = raw[cursor + 1 : end]
|
||
cursor = end + 1
|
||
else:
|
||
match = re.match(r"[A-Za-z0-9_-]+", raw[cursor:])
|
||
if not match:
|
||
return None
|
||
part = match.group(0)
|
||
cursor += len(part)
|
||
parts.append(part)
|
||
while cursor < length and raw[cursor].isspace():
|
||
cursor += 1
|
||
if cursor >= length:
|
||
return tuple(parts)
|
||
if raw[cursor] != ".":
|
||
return None
|
||
cursor += 1
|
||
|
||
@staticmethod
|
||
def _toml_key(value: object) -> str:
|
||
rendered = str(value)
|
||
if re.fullmatch(r"[A-Za-z0-9_-]+", rendered):
|
||
return rendered
|
||
return _toml_string(rendered)
|
||
|
||
@classmethod
|
||
def _toml_value(cls, value: object) -> str:
|
||
"""Serialize values returned by tomllib without changing semantics."""
|
||
if isinstance(value, str):
|
||
return _toml_string(value)
|
||
if isinstance(value, bool):
|
||
return "true" if value else "false"
|
||
if isinstance(value, int):
|
||
return str(value)
|
||
if isinstance(value, float):
|
||
return repr(value)
|
||
if isinstance(value, (datetime, date, datetime_time)):
|
||
return value.isoformat()
|
||
if isinstance(value, list):
|
||
return "[" + ", ".join(cls._toml_value(item) for item in value) + "]"
|
||
if isinstance(value, dict):
|
||
entries = ", ".join(
|
||
f"{cls._toml_key(key)} = {cls._toml_value(item)}"
|
||
for key, item in value.items()
|
||
)
|
||
return f"{{ {entries} }}"
|
||
raise GrokBuildError(
|
||
f"Grok config.toml 含无法安全保留的 TOML 值类型:{type(value).__name__}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _parse_toml_document(content: str, source: str = "Grok config.toml") -> dict:
|
||
if not content.strip():
|
||
return {}
|
||
try:
|
||
parsed = tomllib.loads(content)
|
||
except tomllib.TOMLDecodeError as exc:
|
||
raise GrokBuildError(f"{source} 不是有效 TOML:{exc}") from exc
|
||
if not isinstance(parsed, dict):
|
||
raise GrokBuildError(f"{source} 的根节点必须是 TOML 表")
|
||
return parsed
|
||
|
||
@classmethod
|
||
def _split_toml_assignment(
|
||
cls,
|
||
line: str,
|
||
) -> tuple[tuple[str, ...], int] | None:
|
||
"""Return a real assignment key and the value's starting column."""
|
||
cursor = 0
|
||
quote: str | None = None
|
||
while cursor < len(line):
|
||
character = line[cursor]
|
||
if quote == '"':
|
||
if character == "\\":
|
||
cursor += 2
|
||
continue
|
||
if character == '"':
|
||
quote = None
|
||
cursor += 1
|
||
continue
|
||
if quote == "'":
|
||
if character == "'":
|
||
quote = None
|
||
cursor += 1
|
||
continue
|
||
if character == "#":
|
||
return None
|
||
if character in {'"', "'"}:
|
||
quote = character
|
||
cursor += 1
|
||
continue
|
||
if character == "=":
|
||
path = cls._parse_toml_key_path(line[:cursor].strip())
|
||
return (path, cursor + 1) if path else None
|
||
cursor += 1
|
||
return None
|
||
|
||
@staticmethod
|
||
def _toml_value_end(
|
||
lines: Sequence[str],
|
||
start: int,
|
||
value_column: int,
|
||
) -> int:
|
||
"""Find the final line of one syntactically valid TOML value."""
|
||
multiline: str | None = None
|
||
quote: str | None = None
|
||
depth: list[str] = []
|
||
matching = {"]": "[", "}": "{"}
|
||
for line_index in range(start, len(lines)):
|
||
line = lines[line_index]
|
||
cursor = value_column if line_index == start else 0
|
||
while cursor < len(line):
|
||
if multiline is not None:
|
||
end = line.find(multiline, cursor)
|
||
if end < 0:
|
||
cursor = len(line)
|
||
continue
|
||
if multiline == '"""':
|
||
backslashes = 0
|
||
check = end - 1
|
||
while check >= 0 and line[check] == "\\":
|
||
backslashes += 1
|
||
check -= 1
|
||
if backslashes % 2:
|
||
cursor = end + 3
|
||
continue
|
||
multiline = None
|
||
cursor = end + 3
|
||
continue
|
||
if quote == '"':
|
||
if line[cursor] == "\\":
|
||
cursor += 2
|
||
elif line[cursor] == '"':
|
||
quote = None
|
||
cursor += 1
|
||
else:
|
||
cursor += 1
|
||
continue
|
||
if quote == "'":
|
||
if line[cursor] == "'":
|
||
quote = None
|
||
cursor += 1
|
||
continue
|
||
if line.startswith('"""', cursor):
|
||
multiline = '"""'
|
||
cursor += 3
|
||
continue
|
||
if line.startswith("'''", cursor):
|
||
multiline = "'''"
|
||
cursor += 3
|
||
continue
|
||
character = line[cursor]
|
||
if character == "#":
|
||
break
|
||
if character in {'"', "'"}:
|
||
quote = character
|
||
elif character in "[{":
|
||
depth.append(character)
|
||
elif character in "]}":
|
||
if depth and depth[-1] == matching[character]:
|
||
depth.pop()
|
||
cursor += 1
|
||
if multiline is None and quote is None and not depth:
|
||
return line_index
|
||
return len(lines) - 1
|
||
|
||
@classmethod
|
||
def _toml_assignments(
|
||
cls,
|
||
lines: Sequence[str],
|
||
outside: Sequence[bool],
|
||
headers: Mapping[int, tuple[str, tuple[str, ...] | None]],
|
||
) -> dict[int, tuple[int, tuple[str, ...]]]:
|
||
"""Map assignment start lines to their end line and semantic key path."""
|
||
assignments: dict[int, tuple[int, tuple[str, ...]]] = {}
|
||
current_table: tuple[str, ...] = ()
|
||
skip_until = -1
|
||
for index, line in enumerate(lines):
|
||
header = headers.get(index)
|
||
if header is not None:
|
||
_kind, section = header
|
||
current_table = section or ()
|
||
continue
|
||
if index <= skip_until or not outside[index]:
|
||
continue
|
||
assignment = cls._split_toml_assignment(line)
|
||
if assignment is None:
|
||
continue
|
||
key_path, value_column = assignment
|
||
end = cls._toml_value_end(lines, index, value_column)
|
||
assignments[index] = (end, current_table + key_path)
|
||
skip_until = end
|
||
return assignments
|
||
|
||
@classmethod
|
||
def _parse_toml_header(
|
||
cls,
|
||
line: str,
|
||
) -> tuple[str, tuple[str, ...] | None] | None:
|
||
"""Parse a table header while allowing brackets inside quoted keys."""
|
||
stripped = line.lstrip()
|
||
if not stripped.startswith("["):
|
||
return None
|
||
array = stripped.startswith("[[")
|
||
opening = 2 if array else 1
|
||
cursor = opening
|
||
quote: str | None = None
|
||
closing_start = -1
|
||
while cursor < len(stripped):
|
||
character = stripped[cursor]
|
||
if quote == '"':
|
||
if character == "\\":
|
||
cursor += 2
|
||
continue
|
||
if character == '"':
|
||
quote = None
|
||
cursor += 1
|
||
continue
|
||
if quote == "'":
|
||
if character == "'":
|
||
quote = None
|
||
cursor += 1
|
||
continue
|
||
if character in {'"', "'"}:
|
||
quote = character
|
||
cursor += 1
|
||
continue
|
||
if array and stripped.startswith("]]", cursor):
|
||
closing_start = cursor
|
||
cursor += 2
|
||
break
|
||
if not array and character == "]":
|
||
closing_start = cursor
|
||
cursor += 1
|
||
break
|
||
cursor += 1
|
||
if closing_start < 0 or quote is not None:
|
||
return None
|
||
remainder = stripped[cursor:].strip()
|
||
if remainder and not remainder.startswith("#"):
|
||
return None
|
||
raw_path = stripped[opening:closing_start]
|
||
return (
|
||
"array" if array else "table",
|
||
cls._parse_toml_key_path(raw_path),
|
||
)
|
||
|
||
@classmethod
|
||
def _toml_structure(
|
||
cls,
|
||
lines: Sequence[str],
|
||
) -> tuple[
|
||
list[bool],
|
||
dict[int, tuple[str, tuple[str, ...] | None]],
|
||
]:
|
||
"""Locate real TOML table headers without matching multiline strings."""
|
||
outside_at_start: list[bool] = []
|
||
headers: dict[int, tuple[str, tuple[str, ...] | None]] = {}
|
||
multiline: str | None = None
|
||
containers: list[str] = []
|
||
matching = {"]": "[", "}": "{"}
|
||
|
||
def escaped(text: str, position: int) -> bool:
|
||
backslashes = 0
|
||
cursor = position - 1
|
||
while cursor >= 0 and text[cursor] == "\\":
|
||
backslashes += 1
|
||
cursor -= 1
|
||
return backslashes % 2 == 1
|
||
|
||
for index, line in enumerate(lines):
|
||
starts_outside = multiline is None and not containers
|
||
outside_at_start.append(starts_outside)
|
||
if starts_outside:
|
||
header = cls._parse_toml_header(line)
|
||
if header is not None:
|
||
headers[index] = header
|
||
|
||
cursor = 0
|
||
length = len(line)
|
||
while cursor < length:
|
||
if multiline is not None:
|
||
delimiter = multiline
|
||
end = line.find(delimiter, cursor)
|
||
while (
|
||
end >= 0
|
||
and delimiter == '"""'
|
||
and escaped(line, end)
|
||
):
|
||
end = line.find(delimiter, end + 3)
|
||
if end < 0:
|
||
break
|
||
multiline = None
|
||
cursor = end + 3
|
||
continue
|
||
|
||
if line.startswith('"""', cursor):
|
||
multiline = '"""'
|
||
cursor += 3
|
||
continue
|
||
if line.startswith("'''", cursor):
|
||
multiline = "'''"
|
||
cursor += 3
|
||
continue
|
||
character = line[cursor]
|
||
if character == "#":
|
||
break
|
||
if character == '"':
|
||
cursor += 1
|
||
while cursor < length:
|
||
if line[cursor] == "\\":
|
||
cursor += 2
|
||
elif line[cursor] == '"':
|
||
cursor += 1
|
||
break
|
||
else:
|
||
cursor += 1
|
||
continue
|
||
if character == "'":
|
||
end = line.find("'", cursor + 1)
|
||
cursor = length if end < 0 else end + 1
|
||
continue
|
||
if character in "[{":
|
||
containers.append(character)
|
||
elif (
|
||
character in "]}"
|
||
and containers
|
||
and containers[-1] == matching[character]
|
||
):
|
||
containers.pop()
|
||
cursor += 1
|
||
return outside_at_start, headers
|
||
|
||
@classmethod
|
||
def _without_managed_runtime_sections(
|
||
cls,
|
||
content: str,
|
||
*,
|
||
remove_owned_sections: bool = True,
|
||
remove_permission: bool = False,
|
||
) -> str:
|
||
"""Remove the managed block and optionally other bridge-owned tables."""
|
||
cls._parse_toml_document(content)
|
||
lines = content.splitlines()
|
||
outside, headers = cls._toml_structure(lines)
|
||
assignments = cls._toml_assignments(lines, outside, headers)
|
||
output: list[str] = []
|
||
skip_section = False
|
||
skip_assignment_until = -1
|
||
in_managed_block = False
|
||
begin_count = sum(
|
||
outside[index] and line.strip() == MANAGED_CONFIG_BEGIN
|
||
for index, line in enumerate(lines)
|
||
)
|
||
end_count = sum(
|
||
outside[index] and line.strip() == MANAGED_CONFIG_END
|
||
for index, line in enumerate(lines)
|
||
)
|
||
if begin_count != end_count or begin_count > 1:
|
||
raise GrokBuildError(
|
||
"Grok config.toml 中的企业微信 RPA 自动配置区块标记不唯一或不完整"
|
||
)
|
||
for index, line in enumerate(lines):
|
||
if index <= skip_assignment_until:
|
||
continue
|
||
stripped = line.strip()
|
||
if outside[index] and stripped == MANAGED_CONFIG_BEGIN:
|
||
if in_managed_block:
|
||
raise GrokBuildError("Grok 自动配置区块出现嵌套起始标记")
|
||
in_managed_block = True
|
||
continue
|
||
if outside[index] and stripped == MANAGED_CONFIG_END:
|
||
if not in_managed_block:
|
||
raise GrokBuildError("Grok 自动配置区块缺少起始标记")
|
||
in_managed_block = False
|
||
continue
|
||
if in_managed_block:
|
||
continue
|
||
|
||
header = headers.get(index)
|
||
if header is not None:
|
||
kind, section = header
|
||
skip_section = (
|
||
remove_owned_sections
|
||
and section is not None
|
||
and (
|
||
section[:1] == ("models",)
|
||
or section[:1] == ("model",)
|
||
or section[:1] == ("ui",)
|
||
or section[:1] == ("suggestions",)
|
||
or section[:1] == ("subagents",)
|
||
or section[:1] == ("goal",)
|
||
or section[:1] == ("auto_mode",)
|
||
or section[:2] == ("compaction", "memory_flush")
|
||
or section[:1] == ("compat",)
|
||
or section[:1] == ("claude_compat",)
|
||
or section[:1] == ("plugins",)
|
||
or (
|
||
remove_permission
|
||
and section[:1] == ("permission",)
|
||
)
|
||
)
|
||
)
|
||
assignment = assignments.get(index)
|
||
if assignment is not None:
|
||
end, path = assignment
|
||
if (
|
||
remove_owned_sections
|
||
and (
|
||
path[:1] == ("models",)
|
||
or path[:1] == ("model",)
|
||
or path[:1] == ("ui",)
|
||
or path[:1] == ("suggestions",)
|
||
or path[:1] == ("subagents",)
|
||
or path[:1] == ("goal",)
|
||
or path[:1] == ("auto_mode",)
|
||
or path[:2] == ("compaction", "memory_flush")
|
||
or path[:1] == ("compat",)
|
||
or path[:1] == ("claude_compat",)
|
||
or path[:1] == ("plugins",)
|
||
or (
|
||
remove_permission
|
||
and path[:1] == ("permission",)
|
||
)
|
||
)
|
||
):
|
||
skip_assignment_until = end
|
||
continue
|
||
if not skip_section:
|
||
output.append(line)
|
||
if in_managed_block:
|
||
raise GrokBuildError(
|
||
"Grok config.toml 中的企业微信 RPA 自动配置区块不完整,请修复区块标记"
|
||
)
|
||
return "\n".join(output).strip()
|
||
|
||
@classmethod
|
||
def _models_table_extras(cls, content: str) -> list[str]:
|
||
"""Keep non-routing ``models`` settings while Agent model keys are owned.
|
||
|
||
Canonicalizing this one small namespace avoids corrupting legal TOML
|
||
that uses dotted keys, escaped quoted keys, or multiline values.
|
||
"""
|
||
parsed = cls._parse_toml_document(content)
|
||
models = parsed.get("models")
|
||
if not isinstance(models, dict):
|
||
return []
|
||
return [
|
||
f"{cls._toml_key(key)} = {cls._toml_value(value)}"
|
||
for key, value in models.items()
|
||
if str(key) not in MANAGED_MODELS_KEYS
|
||
]
|
||
|
||
@classmethod
|
||
def _ui_table_extras(cls, content: str) -> list[str]:
|
||
"""Preserve visual UI preferences but never model-routing UI keys."""
|
||
parsed = cls._parse_toml_document(content)
|
||
ui = parsed.get("ui")
|
||
if not isinstance(ui, dict):
|
||
return []
|
||
return [
|
||
f"{cls._toml_key(key)} = {cls._toml_value(value)}"
|
||
for key, value in ui.items()
|
||
if str(key) not in MANAGED_UI_KEYS
|
||
]
|
||
|
||
@classmethod
|
||
def _nested_table_extras(
|
||
cls,
|
||
content: str,
|
||
path: Sequence[str],
|
||
excluded: set[str] | frozenset[str],
|
||
) -> list[str]:
|
||
parsed: object = cls._parse_toml_document(content)
|
||
for component in path:
|
||
if not isinstance(parsed, dict):
|
||
return []
|
||
parsed = parsed.get(component)
|
||
if not isinstance(parsed, dict):
|
||
return []
|
||
return [
|
||
f"{cls._toml_key(key)} = {cls._toml_value(value)}"
|
||
for key, value in parsed.items()
|
||
if str(key) not in excluded
|
||
]
|
||
|
||
@classmethod
|
||
def _plugin_disabled_names(cls, content: str) -> set[str]:
|
||
parsed = cls._parse_toml_document(content)
|
||
plugins = parsed.get("plugins")
|
||
if not isinstance(plugins, dict):
|
||
return set()
|
||
disabled = plugins.get("disabled")
|
||
if not isinstance(disabled, list):
|
||
return set()
|
||
return {
|
||
str(value).strip()
|
||
for value in disabled
|
||
if isinstance(value, str) and str(value).strip()
|
||
}
|
||
|
||
def _claude_compat_plugin_names(self) -> set[str]:
|
||
"""Return Claude-installed plugin names that Grok would auto-discover."""
|
||
manifest = (
|
||
self.user_home
|
||
/ ".claude"
|
||
/ "plugins"
|
||
/ "installed_plugins.json"
|
||
)
|
||
try:
|
||
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
||
except (FileNotFoundError, OSError, ValueError, TypeError):
|
||
return set()
|
||
plugins = payload.get("plugins") if isinstance(payload, dict) else None
|
||
if not isinstance(plugins, dict):
|
||
return set()
|
||
names: set[str] = set()
|
||
for identifier, installations in plugins.items():
|
||
plugin_id = str(identifier).strip()
|
||
if plugin_id:
|
||
names.add(plugin_id)
|
||
continue
|
||
if not isinstance(installations, list):
|
||
continue
|
||
for installation in installations:
|
||
if not isinstance(installation, dict):
|
||
continue
|
||
install_path = str(
|
||
installation.get("installPath") or ""
|
||
).strip()
|
||
if not install_path:
|
||
continue
|
||
candidate = Path(install_path).parent.name.strip()
|
||
if candidate:
|
||
names.add(candidate)
|
||
return names
|
||
|
||
def _external_compat_mcp_names(self) -> set[str]:
|
||
"""Return MCP names discovered from disabled Cursor/Claude sources."""
|
||
candidates = (
|
||
self.user_home / ".cursor" / "mcp.json",
|
||
self.user_home / ".claude.json",
|
||
self.project_dir / ".cursor" / "mcp.json",
|
||
self.project_dir / ".mcp.json",
|
||
)
|
||
names: set[str] = set()
|
||
|
||
def collect(value: object) -> None:
|
||
if isinstance(value, dict):
|
||
servers = value.get("mcpServers")
|
||
if isinstance(servers, dict):
|
||
names.update(
|
||
str(name).strip()
|
||
for name in servers
|
||
if str(name).strip()
|
||
)
|
||
for child in value.values():
|
||
collect(child)
|
||
elif isinstance(value, list):
|
||
for child in value:
|
||
collect(child)
|
||
|
||
for path in candidates:
|
||
try:
|
||
collect(json.loads(path.read_text(encoding="utf-8")))
|
||
except (FileNotFoundError, OSError, ValueError, TypeError):
|
||
continue
|
||
return names
|
||
|
||
@classmethod
|
||
def _configured_mcp_names(cls, content: str) -> set[str]:
|
||
parsed = cls._parse_toml_document(content)
|
||
servers = parsed.get("mcp_servers")
|
||
if not isinstance(servers, dict):
|
||
return set()
|
||
return {str(name).strip() for name in servers if str(name).strip()}
|
||
|
||
@classmethod
|
||
def _subagents_table_extras(cls, content: str) -> list[str]:
|
||
"""Keep subagent behavior while rewriting role/persona model pins."""
|
||
parsed = cls._parse_toml_document(content)
|
||
subagents = parsed.get("subagents")
|
||
if not isinstance(subagents, dict):
|
||
return []
|
||
preserved = copy.deepcopy(subagents)
|
||
preserved.pop("enabled", None)
|
||
preserved.pop("models", None)
|
||
for collection_name in ("roles", "personas"):
|
||
definitions = preserved.get(collection_name)
|
||
if not isinstance(definitions, dict):
|
||
continue
|
||
for definition in definitions.values():
|
||
if isinstance(definition, dict) and "model" in definition:
|
||
definition["model"] = MODEL_PROFILE
|
||
return [
|
||
f"{cls._toml_key(key)} = {cls._toml_value(value)}"
|
||
for key, value in preserved.items()
|
||
]
|
||
|
||
def _subagent_pin_names(self, content: str) -> set[str]:
|
||
names = set(PINNED_SUBAGENT_NAMES)
|
||
parsed = self._parse_toml_document(content)
|
||
subagents = parsed.get("subagents")
|
||
if isinstance(subagents, dict):
|
||
for field in ("models", "toggle", "roles", "personas"):
|
||
values = subagents.get(field)
|
||
if isinstance(values, dict):
|
||
names.update(str(key) for key in values)
|
||
discovery_roots = (
|
||
self.runtime_home,
|
||
self.project_dir / ".grok",
|
||
)
|
||
for root in discovery_roots:
|
||
for folder_name in ("roles", "personas", "agents"):
|
||
folder = root / folder_name
|
||
try:
|
||
entries = tuple(folder.iterdir())
|
||
except OSError:
|
||
continue
|
||
for entry in entries:
|
||
if not entry.is_file() or entry.suffix.lower() not in {
|
||
".toml",
|
||
".md",
|
||
}:
|
||
continue
|
||
names.add(entry.stem)
|
||
if entry.suffix.lower() != ".md":
|
||
continue
|
||
try:
|
||
header = entry.read_text(
|
||
encoding="utf-8", errors="replace"
|
||
)[:16384]
|
||
except OSError:
|
||
continue
|
||
match = re.search(
|
||
r"(?mi)^\s*name\s*:\s*['\"]?([^'\"\r\n#]+)",
|
||
header,
|
||
)
|
||
if match:
|
||
names.add(match.group(1).strip())
|
||
return {name for name in names if name.strip()}
|
||
|
||
def _write_runtime_config(self, managed_content: str) -> bool:
|
||
try:
|
||
existing = self.user_config_file.read_text(encoding="utf-8")
|
||
except FileNotFoundError:
|
||
existing = ""
|
||
except OSError as exc:
|
||
raise GrokBuildError(f"无法读取 Grok Build 用户配置:{exc}") from exc
|
||
unmanaged_existing = self._without_managed_runtime_sections(
|
||
existing,
|
||
remove_owned_sections=False,
|
||
)
|
||
table_extras = {
|
||
"models": self._models_table_extras(existing),
|
||
"ui": self._ui_table_extras(existing),
|
||
"suggestions": self._nested_table_extras(
|
||
existing,
|
||
("suggestions",),
|
||
{"enabled", "ai_enabled", "ai_model"},
|
||
),
|
||
"subagents": self._subagents_table_extras(existing),
|
||
"goal": self._nested_table_extras(
|
||
existing,
|
||
("goal",),
|
||
{
|
||
"use_current_model_only",
|
||
"planner_model",
|
||
"strategist_model",
|
||
"skeptic_models",
|
||
},
|
||
),
|
||
"auto_mode": self._nested_table_extras(
|
||
existing,
|
||
("auto_mode",),
|
||
{"classifier_model"},
|
||
),
|
||
"compaction.memory_flush": self._nested_table_extras(
|
||
existing,
|
||
("compaction", "memory_flush"),
|
||
{"flush_model"},
|
||
),
|
||
"plugins": self._nested_table_extras(
|
||
unmanaged_existing,
|
||
("plugins",),
|
||
{"disabled"},
|
||
),
|
||
}
|
||
preserved = self._without_managed_runtime_sections(
|
||
existing,
|
||
remove_permission="[permission]" in managed_content,
|
||
)
|
||
additional_subagents = sorted(
|
||
self._subagent_pin_names(existing) - PINNED_SUBAGENT_NAMES
|
||
)
|
||
if additional_subagents and "[subagents.models]" in managed_content:
|
||
pins = "\n".join(
|
||
f"{self._toml_key(name)} = {_toml_string(MODEL_PROFILE)}"
|
||
for name in additional_subagents
|
||
)
|
||
managed_content = managed_content.replace(
|
||
"[subagents.models]",
|
||
f"[subagents.models]\n{pins}",
|
||
1,
|
||
)
|
||
for table_name, extras in table_extras.items():
|
||
if not extras:
|
||
continue
|
||
extra_text = "\n".join(extras).strip()
|
||
table_header = f"[{table_name}]"
|
||
if table_header in managed_content:
|
||
managed_content = managed_content.replace(
|
||
table_header,
|
||
f"{table_header}\n{extra_text}",
|
||
1,
|
||
)
|
||
else:
|
||
managed_content = managed_content.replace(
|
||
MANAGED_CONFIG_END,
|
||
f"{table_header}\n{extra_text}\n\n{MANAGED_CONFIG_END}",
|
||
1,
|
||
)
|
||
pieces = [value for value in (preserved, managed_content.strip()) if value]
|
||
rendered = "\n\n".join(pieces) + "\n"
|
||
self._parse_toml_document(rendered, "生成后的 Grok config.toml")
|
||
changed = not hmac.compare_digest(
|
||
existing.encode("utf-8"),
|
||
rendered.encode("utf-8"),
|
||
)
|
||
if changed:
|
||
self._atomic_write(self.user_config_file, rendered)
|
||
|
||
# Migrate only the legacy file written by older bridge revisions. The
|
||
# filename is reserved by Grok for remotely served enterprise policy.
|
||
try:
|
||
legacy = self.legacy_managed_config_file.read_text(encoding="utf-8")
|
||
except (FileNotFoundError, OSError):
|
||
legacy = ""
|
||
if legacy.startswith("# 由企业微信 RPA"):
|
||
self.legacy_managed_config_file.unlink(missing_ok=True)
|
||
return changed
|
||
|
||
def sync_model_configuration(
|
||
self,
|
||
include_mcp: bool | None = None,
|
||
) -> ModelSyncResult:
|
||
with self._runtime_sync_lock:
|
||
return self._sync_model_configuration_locked(include_mcp)
|
||
|
||
def _sync_model_configuration_locked(
|
||
self,
|
||
include_mcp: bool | None = None,
|
||
) -> ModelSyncResult:
|
||
ai_settings = self.load_ai_settings()
|
||
integration = self.load_integration_settings()
|
||
if include_mcp is None:
|
||
include_mcp = bool(integration.get("sync_mcp_servers", False))
|
||
external_compatibility = bool(
|
||
integration.get("external_compatibility", False)
|
||
)
|
||
try:
|
||
existing_config = self.user_config_file.read_text(encoding="utf-8")
|
||
except (FileNotFoundError, OSError):
|
||
existing_config = ""
|
||
unmanaged_config = self._without_managed_runtime_sections(
|
||
existing_config,
|
||
remove_owned_sections=False,
|
||
)
|
||
disabled_plugins = self._plugin_disabled_names(unmanaged_config)
|
||
disabled_external_mcp_names: set[str] = set()
|
||
if not external_compatibility:
|
||
disabled_plugins.update(self._claude_compat_plugin_names())
|
||
disabled_external_mcp_names = (
|
||
self._external_compat_mcp_names()
|
||
- self._configured_mcp_names(unmanaged_config)
|
||
)
|
||
profile = self.agent_model_profile(ai_settings)
|
||
self.runtime_home.mkdir(parents=True, exist_ok=True)
|
||
include_customer_service_tools = bool(
|
||
integration.get("customer_service_tools", True)
|
||
)
|
||
content = self._render_managed_config(
|
||
profile,
|
||
ai_settings,
|
||
bool(include_mcp),
|
||
include_customer_service_tools,
|
||
external_compatibility=external_compatibility,
|
||
disabled_plugins=tuple(sorted(disabled_plugins)),
|
||
disabled_external_mcp_names=tuple(
|
||
sorted(disabled_external_mcp_names)
|
||
),
|
||
)
|
||
config_changed = self._write_runtime_config(content)
|
||
display_backend = profile.source_backend or profile.api_backend
|
||
display_base_url = profile.source_base_url or profile.base_url
|
||
if profile.compatible:
|
||
if profile.source_backend == "dify":
|
||
message = (
|
||
f"已通过本地工具调用适配器配置 {profile.model}"
|
||
"(Dify Chat Messages)"
|
||
)
|
||
else:
|
||
message = f"已自动配置 {profile.model}({profile.api_backend})"
|
||
else:
|
||
message = f"未启用后台模型:{profile.reason}"
|
||
result = ModelSyncResult(
|
||
compatible=profile.compatible,
|
||
configured=profile.compatible,
|
||
profile=profile.profile,
|
||
model=profile.model,
|
||
base_url=display_base_url,
|
||
api_backend=display_backend,
|
||
config_path=str(self.managed_config_file),
|
||
synced_at=_utc_timestamp(),
|
||
message=message,
|
||
source_base_url=display_base_url,
|
||
source_api_backend=display_backend,
|
||
effective_base_url=profile.base_url,
|
||
effective_api_backend=profile.api_backend,
|
||
adapter_instance_id=profile.adapter_instance_id,
|
||
)
|
||
previous = self.read_sync_result()
|
||
previous_values = asdict(previous)
|
||
current_values = asdict(result)
|
||
previous_values.pop("synced_at", None)
|
||
current_values.pop("synced_at", None)
|
||
if (
|
||
not config_changed
|
||
and bool(previous.synced_at)
|
||
and previous_values == current_values
|
||
):
|
||
return previous
|
||
self._atomic_write(
|
||
self.sync_state_file,
|
||
json.dumps(asdict(result), ensure_ascii=False, indent=2) + "\n",
|
||
)
|
||
return result
|
||
|
||
def prepare_agent_configuration(
|
||
self,
|
||
include_mcp: bool | None = None,
|
||
*,
|
||
verify_endpoint: bool = True,
|
||
probe_timeout: float = 12.0,
|
||
) -> ModelSyncResult:
|
||
"""Refresh process-bound endpoints before launching an Agent process."""
|
||
result = self.sync_model_configuration(include_mcp=include_mcp)
|
||
if not result.compatible:
|
||
raise GrokBuildError(
|
||
result.message
|
||
or "后台 Agent 自有模型配置不兼容,已阻止启动"
|
||
)
|
||
if verify_endpoint:
|
||
probe = self.probe_agent_model(
|
||
force=False,
|
||
timeout=probe_timeout,
|
||
cache_ttl=30.0,
|
||
)
|
||
if not probe.ok:
|
||
raise GrokBuildError(
|
||
probe.message
|
||
or "后台 Agent 自有模型端点或工具调用协议预检失败"
|
||
)
|
||
return result
|
||
|
||
def read_sync_result(self) -> ModelSyncResult:
|
||
try:
|
||
raw = json.loads(self.sync_state_file.read_text(encoding="utf-8"))
|
||
return ModelSyncResult(
|
||
compatible=bool(raw.get("compatible")),
|
||
configured=bool(raw.get("configured")),
|
||
profile=str(raw.get("profile") or MODEL_PROFILE),
|
||
model=str(raw.get("model") or ""),
|
||
base_url=str(raw.get("base_url") or ""),
|
||
api_backend=str(raw.get("api_backend") or ""),
|
||
config_path=str(raw.get("config_path") or self.managed_config_file),
|
||
synced_at=str(raw.get("synced_at") or ""),
|
||
message=str(raw.get("message") or "尚未同步模型配置"),
|
||
source_base_url=str(
|
||
raw.get("source_base_url") or raw.get("base_url") or ""
|
||
),
|
||
source_api_backend=str(
|
||
raw.get("source_api_backend")
|
||
or raw.get("api_backend")
|
||
or ""
|
||
),
|
||
effective_base_url=str(raw.get("effective_base_url") or ""),
|
||
effective_api_backend=str(
|
||
raw.get("effective_api_backend") or ""
|
||
),
|
||
adapter_instance_id=str(
|
||
raw.get("adapter_instance_id") or ""
|
||
),
|
||
)
|
||
except (OSError, ValueError, TypeError):
|
||
return ModelSyncResult(
|
||
compatible=False,
|
||
configured=False,
|
||
profile=MODEL_PROFILE,
|
||
model="",
|
||
base_url="",
|
||
api_backend="",
|
||
config_path=str(self.managed_config_file),
|
||
synced_at="",
|
||
message="尚未同步模型配置",
|
||
)
|
||
|
||
def _managed_config_block(self) -> str:
|
||
try:
|
||
content = self.user_config_file.read_text(encoding="utf-8")
|
||
except (FileNotFoundError, OSError):
|
||
return ""
|
||
lines = content.splitlines()
|
||
outside, _headers = self._toml_structure(lines)
|
||
begins = [
|
||
index
|
||
for index, line in enumerate(lines)
|
||
if outside[index] and line.strip() == MANAGED_CONFIG_BEGIN
|
||
]
|
||
ends = [
|
||
index
|
||
for index, line in enumerate(lines)
|
||
if outside[index] and line.strip() == MANAGED_CONFIG_END
|
||
]
|
||
if len(begins) != 1 or len(ends) != 1 or begins[0] >= ends[0]:
|
||
return ""
|
||
return "\n".join(lines[begins[0] : ends[0] + 1])
|
||
|
||
@staticmethod
|
||
def _toml_references_environment(value: object, variable: str) -> bool:
|
||
if isinstance(value, str):
|
||
return value == variable or f"${{{variable}}}" in value
|
||
if isinstance(value, list):
|
||
return any(
|
||
GrokBuildManager._toml_references_environment(item, variable)
|
||
for item in value
|
||
)
|
||
if isinstance(value, dict):
|
||
return any(
|
||
GrokBuildManager._toml_references_environment(item, variable)
|
||
for item in value.values()
|
||
)
|
||
return False
|
||
|
||
@staticmethod
|
||
def _expected_model_table(profile: ModelProfile) -> dict[str, object]:
|
||
expected: dict[str, object] = {
|
||
"model": profile.model,
|
||
"base_url": profile.base_url,
|
||
"name": f"后台模型 · {profile.model}",
|
||
"env_key": MODEL_API_KEY_ENV,
|
||
"api_backend": profile.api_backend,
|
||
"temperature": profile.temperature,
|
||
"max_completion_tokens": profile.max_completion_tokens,
|
||
"context_window": profile.context_window,
|
||
}
|
||
if profile.source_backend != "dify":
|
||
expected["auth_scheme"] = profile.auth_scheme
|
||
if profile.api_backend == "messages" and profile.auth_scheme == "x_api_key":
|
||
expected["extra_headers"] = {"anthropic-version": "2023-06-01"}
|
||
return expected
|
||
|
||
def _custom_model_routes_are_locked(
|
||
self,
|
||
parsed_config: Mapping[str, object],
|
||
config_content: str,
|
||
) -> bool:
|
||
"""Confirm every built-in Agent model role resolves to our one profile."""
|
||
models = parsed_config.get("models")
|
||
ui = parsed_config.get("ui")
|
||
model_tables = parsed_config.get("model")
|
||
suggestions = parsed_config.get("suggestions")
|
||
subagents = parsed_config.get("subagents")
|
||
goal = parsed_config.get("goal")
|
||
auto_mode = parsed_config.get("auto_mode")
|
||
compaction = parsed_config.get("compaction")
|
||
memory_flush = (
|
||
compaction.get("memory_flush")
|
||
if isinstance(compaction, dict)
|
||
else None
|
||
)
|
||
subagent_models = (
|
||
subagents.get("models")
|
||
if isinstance(subagents, dict)
|
||
else None
|
||
)
|
||
required_subagent_names = self._subagent_pin_names(config_content)
|
||
definitions_safe = True
|
||
if isinstance(subagents, dict):
|
||
for collection_name in ("roles", "personas"):
|
||
definitions = subagents.get(collection_name)
|
||
if not isinstance(definitions, dict):
|
||
continue
|
||
for definition in definitions.values():
|
||
if not isinstance(definition, dict):
|
||
continue
|
||
model = definition.get("model")
|
||
if model is not None and not (
|
||
isinstance(model, str)
|
||
and model.strip() in {"", "inherit", MODEL_PROFILE}
|
||
):
|
||
definitions_safe = False
|
||
break
|
||
return bool(
|
||
isinstance(models, dict)
|
||
and models.get("default") == MODEL_PROFILE
|
||
and models.get("allowed_models") == [MODEL_PROFILE]
|
||
and models.get("web_search") == MODEL_PROFILE
|
||
and models.get("session_summary") == MODEL_PROFILE
|
||
and models.get("image_description") == MODEL_PROFILE
|
||
and models.get("prompt_suggestion") == MODEL_PROFILE
|
||
and isinstance(ui, dict)
|
||
and ui.get("prompt_suggestions") is False
|
||
and ui.get("fork_secondary_model") == MODEL_PROFILE
|
||
and isinstance(model_tables, dict)
|
||
and set(str(key) for key in model_tables) == {MODEL_PROFILE}
|
||
and isinstance(suggestions, dict)
|
||
and suggestions.get("enabled") is False
|
||
and suggestions.get("ai_enabled") is False
|
||
and suggestions.get("ai_model") == MODEL_PROFILE
|
||
and isinstance(subagents, dict)
|
||
and subagents.get("enabled") is True
|
||
and isinstance(subagent_models, dict)
|
||
and required_subagent_names.issubset(
|
||
{str(key) for key in subagent_models}
|
||
)
|
||
and all(
|
||
value == MODEL_PROFILE for value in subagent_models.values()
|
||
)
|
||
and definitions_safe
|
||
and isinstance(goal, dict)
|
||
and goal.get("use_current_model_only") is True
|
||
and not {
|
||
"planner_model",
|
||
"strategist_model",
|
||
"skeptic_models",
|
||
}.intersection(goal)
|
||
and isinstance(auto_mode, dict)
|
||
and auto_mode.get("classifier_model") == MODEL_PROFILE
|
||
and isinstance(memory_flush, dict)
|
||
and memory_flush.get("flush_model") == MODEL_PROFILE
|
||
)
|
||
|
||
def _agent_definition_model_violations(self, workspace: Path) -> list[str]:
|
||
"""Find file-based roles/personas/agents that pin another model."""
|
||
files: set[Path] = set()
|
||
grok_roots = {
|
||
self.runtime_home,
|
||
self.project_dir / ".grok",
|
||
workspace / ".grok",
|
||
}
|
||
for root in grok_roots:
|
||
for folder_name in ("roles", "personas", "agents"):
|
||
folder = root / folder_name
|
||
try:
|
||
files.update(
|
||
entry
|
||
for entry in folder.iterdir()
|
||
if entry.is_file()
|
||
and entry.suffix.lower() in {".toml", ".md"}
|
||
)
|
||
except OSError:
|
||
continue
|
||
plugins = root / "plugins"
|
||
if plugins.is_dir():
|
||
try:
|
||
files.update(
|
||
entry
|
||
for entry in plugins.rglob("*")
|
||
if entry.is_file()
|
||
and entry.parent.name.lower()
|
||
in {"roles", "personas", "agents"}
|
||
and entry.suffix.lower() in {".toml", ".md"}
|
||
)
|
||
except OSError:
|
||
pass
|
||
integration = self.load_integration_settings()
|
||
if bool(integration.get("external_compatibility", False)):
|
||
for folder in (
|
||
workspace / ".claude" / "agents",
|
||
self.user_home / ".claude" / "agents",
|
||
):
|
||
try:
|
||
files.update(
|
||
entry
|
||
for entry in folder.iterdir()
|
||
if entry.is_file() and entry.suffix.lower() == ".md"
|
||
)
|
||
except OSError:
|
||
continue
|
||
|
||
violations: list[str] = []
|
||
accepted = {"", "inherit", MODEL_PROFILE}
|
||
for path in sorted(files, key=lambda item: str(item).casefold()):
|
||
model: object = None
|
||
try:
|
||
content = path.read_text(
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
except OSError:
|
||
continue
|
||
if path.suffix.lower() == ".toml":
|
||
try:
|
||
parsed = tomllib.loads(content)
|
||
except (tomllib.TOMLDecodeError, ValueError, TypeError):
|
||
continue
|
||
model = parsed.get("model") if isinstance(parsed, dict) else None
|
||
else:
|
||
lines = content.splitlines()
|
||
if not lines or lines[0].strip() != "---":
|
||
continue
|
||
frontmatter: list[str] = []
|
||
for line in lines[1:]:
|
||
if line.strip() == "---":
|
||
break
|
||
frontmatter.append(line)
|
||
match = re.search(
|
||
r"(?mi)^\s*model\s*:\s*([^#\r\n]+)",
|
||
"\n".join(frontmatter),
|
||
)
|
||
if match:
|
||
model = match.group(1).strip().strip("'\"")
|
||
if model is None:
|
||
continue
|
||
normalized = model.strip() if isinstance(model, str) else ""
|
||
if not isinstance(model, str) or normalized not in accepted:
|
||
violations.append(f"{path} -> {model!r}")
|
||
return violations
|
||
|
||
def _assert_effective_config_isolated(
|
||
self,
|
||
environment: Mapping[str, str],
|
||
workspace: Path,
|
||
expected_user_config: str,
|
||
) -> None:
|
||
"""Verify that no higher-priority Grok layer can redirect credentials."""
|
||
requirements_file = self.runtime_home / "requirements.toml"
|
||
extra_known_layers = [
|
||
path
|
||
for path in (requirements_file, self.legacy_managed_config_file)
|
||
if path.exists()
|
||
]
|
||
if extra_known_layers:
|
||
raise GrokBuildError(
|
||
"检测到可能覆盖受管模型的 Grok 高优先级配置层:"
|
||
+ "、".join(str(path) for path in extra_known_layers)
|
||
+ ";已阻止凭据注入"
|
||
)
|
||
|
||
binary = self.locate_binary()
|
||
if binary is None:
|
||
# The returned mapping cannot start Grok until a runtime exists.
|
||
# Every bridge launch path calls require_binary() before this point.
|
||
return
|
||
self.validate_binary(binary)
|
||
try:
|
||
completed = subprocess.run(
|
||
[str(binary), "--no-auto-update", "inspect", "--json"],
|
||
cwd=str(workspace),
|
||
env=dict(environment),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=30,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
raise GrokBuildError(
|
||
f"无法核验 Grok 实际配置层;已阻止凭据注入:{exc}"
|
||
) from exc
|
||
if completed.returncode != 0:
|
||
raise GrokBuildError(
|
||
"Grok inspect 无法核验实际配置层;已阻止凭据注入:"
|
||
f"{(completed.stdout or '').strip()}"
|
||
)
|
||
try:
|
||
inspection = json.loads((completed.stdout or "").lstrip("\ufeff"))
|
||
except json.JSONDecodeError as exc:
|
||
raise GrokBuildError(
|
||
"Grok inspect 未返回有效配置层 JSON;已阻止凭据注入"
|
||
) from exc
|
||
config_sources = (
|
||
inspection.get("configSources") if isinstance(inspection, dict) else None
|
||
)
|
||
layers = (
|
||
config_sources.get("layers")
|
||
if isinstance(config_sources, dict)
|
||
else None
|
||
)
|
||
if not isinstance(layers, list):
|
||
raise GrokBuildError(
|
||
"Grok inspect 未提供可验证的 configSources.layers;"
|
||
"已阻止凭据注入"
|
||
)
|
||
expected_path = os.path.normcase(
|
||
os.path.abspath(str(self.user_config_file))
|
||
)
|
||
normalized_layers: list[tuple[str, str]] = []
|
||
for layer in layers:
|
||
if not isinstance(layer, dict):
|
||
normalized_layers.append(("", ""))
|
||
continue
|
||
role = str(layer.get("role") or "").strip().lower()
|
||
raw_path = str(layer.get("path") or "").strip()
|
||
normalized_path = (
|
||
os.path.normcase(os.path.abspath(raw_path))
|
||
if raw_path
|
||
else ""
|
||
)
|
||
normalized_layers.append((role, normalized_path))
|
||
if normalized_layers != [("user", expected_path)]:
|
||
descriptions = [
|
||
f"{role or 'unknown'}:{path or '<missing>'}"
|
||
for role, path in normalized_layers
|
||
]
|
||
raise GrokBuildError(
|
||
"Grok 实际配置含未受管的 requirements/system/MDM/project "
|
||
"或未知覆盖层("
|
||
+ "、".join(descriptions or ["无可验证 user 层"])
|
||
+ ");已阻止凭据注入"
|
||
)
|
||
permissions = (
|
||
inspection.get("permissions") if isinstance(inspection, dict) else None
|
||
)
|
||
if (
|
||
not isinstance(permissions, dict)
|
||
or permissions.get("managedSettingsExists") is not False
|
||
or permissions.get("managedSettingsActive") is not False
|
||
):
|
||
raise GrokBuildError(
|
||
"Grok 托管/MDM 设置状态无法证明为未启用;已阻止凭据注入"
|
||
)
|
||
|
||
try:
|
||
current_config = self.user_config_file.read_text(encoding="utf-8")
|
||
except OSError as exc:
|
||
raise GrokBuildError(
|
||
f"核验后无法重新读取 Grok config.toml;已阻止凭据注入:{exc}"
|
||
) from exc
|
||
if not hmac.compare_digest(
|
||
current_config.encode("utf-8"),
|
||
expected_user_config.encode("utf-8"),
|
||
):
|
||
raise GrokBuildError(
|
||
"Grok config.toml 在配置层核验期间发生变化;已阻止凭据注入"
|
||
)
|
||
if requirements_file.exists() or self.legacy_managed_config_file.exists():
|
||
raise GrokBuildError(
|
||
"Grok 高优先级配置层在核验期间出现;已阻止凭据注入"
|
||
)
|
||
|
||
def runtime_environment(
|
||
self,
|
||
include_model_key: bool = True,
|
||
include_mcp_secrets: bool | None = None,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
custom_model_only: bool = True,
|
||
) -> dict[str, str]:
|
||
env = dict(os.environ)
|
||
pending_secrets: dict[str, str] = {}
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
if not selected_workspace.is_dir():
|
||
raise GrokBuildError(f"工作目录不存在:{selected_workspace}")
|
||
env.pop(MODEL_API_KEY_ENV, None)
|
||
for variable in tuple(env):
|
||
if variable.startswith("WECOM_GROK_MCP_"):
|
||
env.pop(variable, None)
|
||
env["GROK_HOME"] = str(self.runtime_home)
|
||
env["PYTHONUTF8"] = "1"
|
||
env["GROK_DISABLE_AUTOUPDATER"] = "1"
|
||
if custom_model_only:
|
||
for variable in XAI_CREDENTIAL_ENV_VARS:
|
||
env.pop(variable, None)
|
||
for variable in tuple(env):
|
||
if (
|
||
variable in UNMANAGED_MODEL_ROUTE_ENV_VARS
|
||
or variable.startswith("GROK_AUTH_PROVIDER_")
|
||
):
|
||
env.pop(variable, None)
|
||
env.update(CUSTOM_MODEL_ENVIRONMENT)
|
||
no_xai_auth = (self.runtime_home / "no-xai-auth.json").resolve()
|
||
if no_xai_auth.exists():
|
||
raise GrokBuildError(
|
||
f"xAI 隔离认证路径必须不存在:{no_xai_auth}"
|
||
)
|
||
env["GROK_AUTH_PATH"] = str(no_xai_auth)
|
||
if include_mcp_secrets is None:
|
||
include_mcp_secrets = include_model_key
|
||
integration = self.load_integration_settings()
|
||
if not bool(integration.get("external_compatibility", False)):
|
||
for vendor in ("CURSOR", "CLAUDE", "CODEX"):
|
||
for surface in (
|
||
"SKILLS",
|
||
"RULES",
|
||
"AGENTS",
|
||
"MCPS",
|
||
"HOOKS",
|
||
"SESSIONS",
|
||
):
|
||
env[f"GROK_{vendor}_{surface}_ENABLED"] = "false"
|
||
try:
|
||
runtime_config = self.user_config_file.read_text(encoding="utf-8")
|
||
except (FileNotFoundError, OSError):
|
||
runtime_config = ""
|
||
managed_block_present = bool(self._managed_config_block())
|
||
if include_model_key or include_mcp_secrets:
|
||
try:
|
||
settings = self.load_ai_settings()
|
||
except GrokBuildError:
|
||
settings = {}
|
||
parsed_config = self._parse_toml_document(runtime_config)
|
||
else:
|
||
settings = {}
|
||
parsed_config = {}
|
||
models = parsed_config.get("models")
|
||
model_tables = parsed_config.get("model")
|
||
actual_model = (
|
||
model_tables.get(MODEL_PROFILE)
|
||
if isinstance(model_tables, dict)
|
||
else None
|
||
)
|
||
managed_model_exists = isinstance(actual_model, dict)
|
||
if include_model_key and not settings and managed_model_exists:
|
||
raise GrokBuildError(
|
||
"无法读取后台 Agent 自有模型配置;已阻止凭据回退,"
|
||
"请恢复 AI 设置并重新同步"
|
||
)
|
||
if include_model_key and settings:
|
||
profile = (
|
||
self.agent_model_profile(settings)
|
||
if custom_model_only
|
||
else self.model_profile(settings)
|
||
)
|
||
default_model = (
|
||
models.get("default") if isinstance(models, dict) else None
|
||
)
|
||
if (
|
||
profile.compatible
|
||
and actual_model == self._expected_model_table(profile)
|
||
and default_model == MODEL_PROFILE
|
||
and self._custom_model_routes_are_locked(
|
||
parsed_config,
|
||
runtime_config,
|
||
)
|
||
and managed_block_present
|
||
):
|
||
other_config = dict(parsed_config)
|
||
other_models = dict(model_tables) if isinstance(model_tables, dict) else {}
|
||
other_models.pop(MODEL_PROFILE, None)
|
||
other_config["model"] = other_models
|
||
if self._toml_references_environment(
|
||
other_config,
|
||
MODEL_API_KEY_ENV,
|
||
):
|
||
raise GrokBuildError(
|
||
f"Grok 配置在受管模型之外引用 {MODEL_API_KEY_ENV};"
|
||
"已阻止密钥注入"
|
||
)
|
||
definition_violations = self._agent_definition_model_violations(
|
||
selected_workspace
|
||
)
|
||
if definition_violations:
|
||
raise GrokBuildError(
|
||
"检测到角色/Persona/Agent 文件指定了非受管模型;"
|
||
"已阻止启动,请改为 wecom-backend 或 inherit:"
|
||
+ "、".join(definition_violations)
|
||
)
|
||
if custom_model_only:
|
||
key = self.agent_model_api_key(
|
||
settings,
|
||
profile=profile,
|
||
)
|
||
else:
|
||
key_name = (
|
||
"GROK_API_KEY"
|
||
if bool(settings.get("GROK_MODEL_ENABLED", False))
|
||
else "AI_API_KEY"
|
||
)
|
||
key = str(settings.get(key_name) or "").strip()
|
||
if key:
|
||
pending_secrets[MODEL_API_KEY_ENV] = key
|
||
# Do not allow an inherited xAI global credential to become
|
||
# a fallback for this third-party/custom provider.
|
||
env.pop("XAI_API_KEY", None)
|
||
else:
|
||
raise GrokBuildError(
|
||
"受管 Agent 自有模型缺少独立 API Key,请先在后台配置并重新同步"
|
||
)
|
||
elif managed_model_exists:
|
||
raise GrokBuildError(
|
||
"后台 Agent 自有模型与 Grok 受管配置不一致;已阻止凭据回退,"
|
||
"请先同步后台模型再启动任务"
|
||
)
|
||
elif custom_model_only:
|
||
raise GrokBuildError(
|
||
f"{profile.reason};请先同步后台自有模型再启动 Agent"
|
||
)
|
||
if include_mcp_secrets and settings:
|
||
raw_servers = settings.get("AI_MCP_SERVERS")
|
||
integration = self.load_integration_settings()
|
||
actual_mcp_tables = parsed_config.get("mcp_servers")
|
||
actual_managed = (
|
||
{
|
||
str(name): value
|
||
for name, value in actual_mcp_tables.items()
|
||
if str(name).startswith(MANAGED_MCP_PREFIX)
|
||
}
|
||
if isinstance(actual_mcp_tables, dict)
|
||
else {}
|
||
)
|
||
actual_has_external_mcp = any(
|
||
name != CUSTOMER_SERVICE_MCP_NAME for name in actual_managed
|
||
)
|
||
expected_lines: list[str] = []
|
||
expected_servers: list[object] = []
|
||
if bool(integration.get("customer_service_tools", True)):
|
||
expected_servers.extend(self._customer_service_mcp_servers(settings))
|
||
if actual_has_external_mcp and isinstance(raw_servers, list):
|
||
expected_servers.extend(raw_servers)
|
||
if expected_servers:
|
||
expected_lines.extend(self._render_mcp_servers(expected_servers))
|
||
rendered_mcp = "\n".join(expected_lines).strip()
|
||
expected_mcp_config = (
|
||
self._parse_toml_document(rendered_mcp, "后台 MCP 配置")
|
||
if rendered_mcp
|
||
else {}
|
||
)
|
||
expected_mcp_tables = expected_mcp_config.get("mcp_servers")
|
||
expected_managed = (
|
||
{
|
||
str(name): value
|
||
for name, value in expected_mcp_tables.items()
|
||
if str(name).startswith(MANAGED_MCP_PREFIX)
|
||
}
|
||
if isinstance(expected_mcp_tables, dict)
|
||
else {}
|
||
)
|
||
if actual_managed and actual_managed != expected_managed:
|
||
raise GrokBuildError(
|
||
"后台 MCP 配置与 Grok 受管配置不一致;已阻止凭据注入,"
|
||
"请重新同步 MCP 配置"
|
||
)
|
||
if actual_managed and not managed_block_present:
|
||
raise GrokBuildError(
|
||
"Grok 受管 MCP 配置缺少唯一自动配置区块标记;"
|
||
"已阻止凭据注入,请重新同步 MCP 配置"
|
||
)
|
||
if actual_managed:
|
||
config_without_managed_mcp = dict(parsed_config)
|
||
config_without_managed_mcp["mcp_servers"] = {
|
||
str(name): value
|
||
for name, value in (
|
||
actual_mcp_tables.items()
|
||
if isinstance(actual_mcp_tables, dict)
|
||
else ()
|
||
)
|
||
if not str(name).startswith(MANAGED_MCP_PREFIX)
|
||
}
|
||
used: set[str] = (
|
||
{CUSTOMER_SERVICE_MCP_NAME}
|
||
if bool(integration.get("customer_service_tools", True))
|
||
else set()
|
||
)
|
||
for index, raw in enumerate(raw_servers, start=1):
|
||
if not isinstance(raw, dict) or raw.get("enabled") is False:
|
||
continue
|
||
base_name = self._safe_toml_identifier(
|
||
str(raw.get("name") or raw.get("id") or f"server-{index}")
|
||
)
|
||
base_name = f"{MANAGED_MCP_PREFIX}{base_name}"
|
||
name = base_name
|
||
suffix = 2
|
||
while name in used:
|
||
name = f"{base_name}-{suffix}"
|
||
suffix += 1
|
||
used.add(name)
|
||
if name not in actual_managed:
|
||
continue
|
||
for category, values in (
|
||
("env", raw.get("env")),
|
||
("header", raw.get("headers")),
|
||
):
|
||
if not isinstance(values, dict):
|
||
continue
|
||
for item_key, item_value in values.items():
|
||
rendered = str(item_value)
|
||
if re.fullmatch(
|
||
r"\$\{[A-Za-z_][A-Za-z0-9_]*\}",
|
||
rendered,
|
||
):
|
||
continue
|
||
variable = self._mcp_value_env_name(
|
||
name, category, item_key
|
||
)
|
||
if self._toml_references_environment(
|
||
config_without_managed_mcp,
|
||
variable,
|
||
):
|
||
raise GrokBuildError(
|
||
f"Grok 配置在受管 MCP 之外引用 {variable};"
|
||
"已阻止密钥注入"
|
||
)
|
||
pending_secrets[variable] = rendered
|
||
if pending_secrets:
|
||
self._assert_effective_config_isolated(
|
||
env,
|
||
selected_workspace,
|
||
runtime_config,
|
||
)
|
||
env.update(pending_secrets)
|
||
return env
|
||
|
||
def locate_binary(self) -> Path | None:
|
||
integration = self.load_integration_settings()
|
||
candidates: list[Path] = []
|
||
override = str(os.environ.get("GROK_BUILD_BIN") or "").strip()
|
||
configured = str(integration.get("binary_path") or "").strip()
|
||
if override:
|
||
candidates.append(Path(override).expanduser())
|
||
if configured:
|
||
candidates.append(Path(configured).expanduser())
|
||
candidates.append(self.binary_path)
|
||
seen: set[str] = set()
|
||
for candidate in candidates:
|
||
normalized = os.path.normcase(os.path.abspath(str(candidate)))
|
||
if normalized in seen:
|
||
continue
|
||
seen.add(normalized)
|
||
if candidate.is_file():
|
||
return candidate.resolve()
|
||
return None
|
||
|
||
@staticmethod
|
||
def _file_sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(block)
|
||
return digest.hexdigest()
|
||
|
||
def validate_binary(self, binary: Path) -> None:
|
||
target = binary.resolve()
|
||
stat = target.stat()
|
||
fingerprint = (str(target), stat.st_size, stat.st_mtime_ns)
|
||
if self._validated_binary_fingerprint == fingerprint:
|
||
return
|
||
if stat.st_size < 1024 * 1024:
|
||
raise GrokBuildError(f"Grok Build 运行时文件异常:{target}")
|
||
|
||
trusted_install_record = False
|
||
if target == self.binary_path.resolve() and self.install_state_file.is_file():
|
||
try:
|
||
state = json.loads(
|
||
self.install_state_file.read_text(encoding="utf-8")
|
||
)
|
||
expected = str(state.get("sha256") or "").strip().lower()
|
||
source = str(state.get("source") or "").strip()
|
||
publisher = str(state.get("publisher") or "").strip()
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
raise GrokBuildError(f"Grok Build 安装记录损坏:{exc}") from exc
|
||
if expected:
|
||
actual = self._file_sha256(target)
|
||
if not hmac.compare_digest(actual, expected):
|
||
raise GrokBuildError("Grok Build 运行时 SHA-256 与安装记录不一致")
|
||
trusted_install_record = (
|
||
source
|
||
in {
|
||
"x.ai/cli",
|
||
OFFICIAL_BASE_URL,
|
||
OFFICIAL_FALLBACK_URL,
|
||
}
|
||
and bool(publisher)
|
||
and re.search(
|
||
r"(?:^|,\s*)CN=X\.AI LLC(?:,|$)",
|
||
publisher.upper(),
|
||
)
|
||
is not None
|
||
)
|
||
# The installer performs Authenticode verification before recording the
|
||
# SHA-256. Re-hashing that exact managed binary is sufficient on later
|
||
# launches and avoids a slow certificate-chain lookup at every startup.
|
||
# Explicit external binaries and legacy records are always re-verified.
|
||
if os.name == "nt" and not trusted_install_record:
|
||
self._verify_windows_signature(target)
|
||
self._validated_binary_fingerprint = fingerprint
|
||
|
||
def version(self, binary: Path | None = None, timeout: float = 8.0) -> str:
|
||
target = binary or self.locate_binary()
|
||
if target is None:
|
||
return ""
|
||
self.validate_binary(target)
|
||
try:
|
||
completed = subprocess.run(
|
||
[str(target), "--version"],
|
||
cwd=str(self.project_dir),
|
||
env=self.runtime_environment(include_model_key=False),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=timeout,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError):
|
||
return ""
|
||
first_line = (completed.stdout or "").strip().splitlines()
|
||
return first_line[0].strip() if first_line else ""
|
||
|
||
@staticmethod
|
||
def _dify_adapter_instance_live(sync: ModelSyncResult) -> bool:
|
||
base_url = str(sync.effective_base_url or "").strip()
|
||
instance_id = str(sync.adapter_instance_id or "").strip()
|
||
if not base_url or not instance_id:
|
||
return False
|
||
try:
|
||
parsed = urlsplit(base_url)
|
||
port = parsed.port
|
||
except ValueError:
|
||
return False
|
||
if (
|
||
parsed.scheme != "http"
|
||
or parsed.hostname != "127.0.0.1"
|
||
or not port
|
||
):
|
||
return False
|
||
health_url = urlunsplit(
|
||
(parsed.scheme, parsed.netloc, "/health", "", "")
|
||
)
|
||
request = urllib.request.Request(
|
||
health_url,
|
||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=0.75) as response:
|
||
if (
|
||
int(response.getcode()) != 200
|
||
or str(response.headers.get("X-Grok-Dify-Adapter") or "")
|
||
!= "1"
|
||
):
|
||
return False
|
||
payload = json.loads(response.read(4097).decode("utf-8"))
|
||
except (OSError, ValueError, urllib.error.URLError):
|
||
return False
|
||
return bool(
|
||
isinstance(payload, dict)
|
||
and payload.get("ok") is True
|
||
and payload.get("adapter") == "dify"
|
||
and hmac.compare_digest(
|
||
str(payload.get("instance_id") or ""),
|
||
instance_id,
|
||
)
|
||
)
|
||
|
||
def status(self) -> RuntimeStatus:
|
||
binary = self.locate_binary()
|
||
sync = self.read_sync_result()
|
||
source_backend = sync.source_api_backend or sync.api_backend
|
||
adapter_live = (
|
||
self._dify_adapter_instance_live(sync)
|
||
if source_backend == "dify"
|
||
else False
|
||
)
|
||
return RuntimeStatus(
|
||
installed=binary is not None,
|
||
binary_path=str(binary or self.binary_path),
|
||
version=self.version(binary) if binary else "",
|
||
# Kept for backward-compatible status JSON. xAI authentication is
|
||
# disabled and is never considered a readiness signal.
|
||
authenticated=False,
|
||
runtime_home=str(self.runtime_home),
|
||
model_configured=sync.configured,
|
||
model_compatible=sync.compatible,
|
||
model_name=sync.model,
|
||
model_message=sync.message,
|
||
model_api_backend=source_backend,
|
||
model_effective_base_url=sync.effective_base_url,
|
||
adapter_live=adapter_live,
|
||
warnings=tuple(self.migration_warnings),
|
||
)
|
||
|
||
@staticmethod
|
||
def _request(url: str, timeout: float = 30.0):
|
||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||
return urllib.request.urlopen(request, timeout=timeout)
|
||
|
||
def latest_version(self, channel: str | None = None) -> str:
|
||
selected = str(
|
||
channel or self.load_integration_settings().get("runtime_channel") or "stable"
|
||
).strip()
|
||
if selected not in {"stable", "alpha", "enterprise"}:
|
||
raise GrokBuildError(f"不支持的更新通道:{selected}")
|
||
errors: list[str] = []
|
||
for base_url in (OFFICIAL_BASE_URL, OFFICIAL_FALLBACK_URL):
|
||
try:
|
||
with self._request(f"{base_url}/{selected}", timeout=20) as response:
|
||
version = response.read().decode("utf-8", "replace").strip()
|
||
except (OSError, urllib.error.URLError) as exc:
|
||
errors.append(str(exc))
|
||
continue
|
||
if VERSION_PATTERN.fullmatch(version):
|
||
return version
|
||
errors.append(f"{base_url} 返回了无效版本号")
|
||
raise GrokBuildError("无法获取 Grok Build 最新版本:" + ";".join(errors))
|
||
|
||
@staticmethod
|
||
def _platform_tag() -> str:
|
||
machine = platform.machine().lower()
|
||
if machine in {"amd64", "x86_64", "x64"}:
|
||
architecture = "x86_64"
|
||
elif machine in {"arm64", "aarch64"}:
|
||
architecture = "aarch64"
|
||
else:
|
||
raise GrokBuildError(f"不支持的处理器架构:{platform.machine()}")
|
||
if os.name == "nt":
|
||
return f"windows-{architecture}"
|
||
if sys.platform == "darwin":
|
||
return f"darwin-{architecture}"
|
||
if sys.platform.startswith("linux"):
|
||
return f"linux-{architecture}"
|
||
raise GrokBuildError(f"不支持的操作系统:{sys.platform}")
|
||
|
||
@staticmethod
|
||
def _verify_windows_signature(path: Path) -> str:
|
||
if os.name != "nt":
|
||
return ""
|
||
system_root = Path(os.environ.get("SystemRoot") or r"C:\Windows")
|
||
powershell = (
|
||
system_root
|
||
/ "System32"
|
||
/ "WindowsPowerShell"
|
||
/ "v1.0"
|
||
/ "powershell.exe"
|
||
)
|
||
if not powershell.is_file():
|
||
discovered = shutil.which("powershell") or shutil.which("pwsh")
|
||
if not discovered:
|
||
raise GrokBuildError("无法校验官方运行时数字签名:未找到 PowerShell")
|
||
powershell = Path(discovered)
|
||
script = (
|
||
"& { param([string]$p) "
|
||
"$s = Get-AuthenticodeSignature -LiteralPath $p; "
|
||
"[ordered]@{status=[string]$s.Status;"
|
||
"subject=[string]$s.SignerCertificate.Subject;"
|
||
"simple_name=[string]$s.SignerCertificate.GetNameInfo("
|
||
"[System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName,"
|
||
"$false)} | ConvertTo-Json -Compress }"
|
||
)
|
||
try:
|
||
completed = subprocess.run(
|
||
[
|
||
str(powershell),
|
||
"-NoLogo",
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
script,
|
||
str(path),
|
||
],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=45,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.SubprocessError) as exc:
|
||
raise GrokBuildError(f"无法校验官方运行时数字签名:{exc}") from exc
|
||
try:
|
||
result = json.loads((completed.stdout or "").strip())
|
||
except (ValueError, TypeError) as exc:
|
||
detail = (completed.stderr or completed.stdout or "没有签名校验输出").strip()
|
||
raise GrokBuildError(f"无法解析数字签名结果:{detail}") from exc
|
||
status = str(result.get("status") or "")
|
||
subject = str(result.get("subject") or "")
|
||
simple_name = str(result.get("simple_name") or "").strip().casefold()
|
||
if completed.returncode != 0 or status.lower() != "valid":
|
||
raise GrokBuildError(f"Grok Build 数字签名无效:{status or 'Unknown'}")
|
||
if simple_name not in {"x.ai llc", "xai llc"}:
|
||
raise GrokBuildError(f"Grok Build 发布者不受信任:{subject or 'Unknown'}")
|
||
return subject
|
||
|
||
def install_official_release(
|
||
self,
|
||
version: str | None = None,
|
||
progress: Callable[[int, int], None] | None = None,
|
||
) -> RuntimeStatus:
|
||
resolved_version = (version or self.latest_version()).strip()
|
||
if not VERSION_PATTERN.fullmatch(resolved_version):
|
||
raise GrokBuildError(f"无效版本号:{resolved_version}")
|
||
tag = self._platform_tag()
|
||
extension = ".exe" if os.name == "nt" else ""
|
||
artifact = f"grok-{resolved_version}-{tag}{extension}"
|
||
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
||
downloads = self.binary_store_home / "downloads"
|
||
downloads.mkdir(parents=True, exist_ok=True)
|
||
temporary = downloads / f"{artifact}.{os.getpid()}.part"
|
||
errors: list[str] = []
|
||
downloaded = False
|
||
download_source = ""
|
||
for base_url in (OFFICIAL_BASE_URL, OFFICIAL_FALLBACK_URL):
|
||
url = f"{base_url}/{artifact}"
|
||
try:
|
||
with self._request(url, timeout=300) as response, temporary.open("wb") as target:
|
||
try:
|
||
total = int(response.headers.get("Content-Length") or 0)
|
||
except (TypeError, ValueError):
|
||
total = 0
|
||
received = 0
|
||
while True:
|
||
block = response.read(1024 * 256)
|
||
if not block:
|
||
break
|
||
target.write(block)
|
||
received += len(block)
|
||
if progress:
|
||
progress(received, total)
|
||
downloaded = True
|
||
download_source = base_url
|
||
break
|
||
except (OSError, urllib.error.URLError) as exc:
|
||
errors.append(f"{url}: {exc}")
|
||
try:
|
||
temporary.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
if not downloaded:
|
||
raise GrokBuildError("官方运行时下载失败:" + ";".join(errors))
|
||
size = temporary.stat().st_size
|
||
if size < 1024 * 1024:
|
||
temporary.unlink(missing_ok=True)
|
||
raise GrokBuildError(f"下载文件异常,仅 {size} 字节")
|
||
if os.name == "nt":
|
||
with temporary.open("rb") as handle:
|
||
if handle.read(2) != b"MZ":
|
||
temporary.unlink(missing_ok=True)
|
||
raise GrokBuildError("下载文件不是有效的 Windows 可执行文件")
|
||
publisher = self._verify_windows_signature(temporary)
|
||
else:
|
||
temporary.chmod(0o755)
|
||
publisher = ""
|
||
digest = hashlib.sha256()
|
||
with temporary.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(block)
|
||
sha256 = digest.hexdigest()
|
||
os.replace(temporary, self.binary_path)
|
||
if os.name != "nt":
|
||
self.binary_path.chmod(0o755)
|
||
try:
|
||
if self.agent_alias_path.exists():
|
||
self.agent_alias_path.unlink()
|
||
os.link(self.binary_path, self.agent_alias_path)
|
||
except OSError:
|
||
shutil.copy2(self.binary_path, self.agent_alias_path)
|
||
self._atomic_write(
|
||
self.install_state_file,
|
||
json.dumps(
|
||
{
|
||
"version": resolved_version,
|
||
"platform": tag,
|
||
"installed_at": _utc_timestamp(),
|
||
"binary_path": str(self.binary_path),
|
||
"source": download_source,
|
||
"sha256": sha256,
|
||
"publisher": publisher,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
+ "\n",
|
||
)
|
||
return self.status()
|
||
|
||
def require_binary(self) -> Path:
|
||
binary = self.locate_binary()
|
||
if binary is None:
|
||
raise GrokBuildError("尚未安装 Grok Build,请先执行安装")
|
||
self.validate_binary(binary)
|
||
return binary
|
||
|
||
def build_headless_args(
|
||
self,
|
||
prompt: str,
|
||
*,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
model: str = "",
|
||
effort: str = "high",
|
||
max_turns: int = 50,
|
||
auto_approve: bool = False,
|
||
read_only: bool = False,
|
||
continue_session: bool = False,
|
||
new_session_id: str = "",
|
||
resume_session: str = "",
|
||
sandbox: str = "",
|
||
allowed_tools: str = "",
|
||
disallowed_tools: str = "",
|
||
rules: str = "",
|
||
) -> list[str]:
|
||
if not prompt.strip():
|
||
raise GrokBuildError("任务内容不能为空")
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
if not selected_workspace.is_dir():
|
||
raise GrokBuildError(f"工作目录不存在:{selected_workspace}")
|
||
args = [
|
||
"-p",
|
||
prompt,
|
||
"--cwd",
|
||
str(selected_workspace),
|
||
"--output-format",
|
||
"streaming-json",
|
||
"--max-turns",
|
||
str(min(10000, max(1, int(max_turns)))),
|
||
"--no-auto-update",
|
||
]
|
||
if model.strip():
|
||
args.extend(["--model", model.strip()])
|
||
if effort.strip():
|
||
args.extend(["--reasoning-effort", effort.strip()])
|
||
if auto_approve:
|
||
args.append("--yolo")
|
||
if read_only:
|
||
allowed_tools = "read_file,grep,list_dir,web_search,web_fetch"
|
||
mandatory_denied = {"search_tool", "use_tool", "Agent"}
|
||
mandatory_denied.update(
|
||
item.strip() for item in disallowed_tools.split(",") if item.strip()
|
||
)
|
||
disallowed_tools = ",".join(sorted(mandatory_denied))
|
||
args.append("--no-subagents")
|
||
if self._agent_uses_dify_source():
|
||
denied = [
|
||
item.strip()
|
||
for item in disallowed_tools.split(",")
|
||
if item.strip()
|
||
]
|
||
if "web_search" not in denied:
|
||
denied.append("web_search")
|
||
disallowed_tools = ",".join(denied)
|
||
if allowed_tools.strip():
|
||
args.extend(["--tools", allowed_tools.strip()])
|
||
if disallowed_tools.strip():
|
||
args.extend(["--disallowed-tools", disallowed_tools.strip()])
|
||
if new_session_id.strip() and (
|
||
resume_session.strip() or continue_session
|
||
):
|
||
raise GrokBuildError(
|
||
"新会话、恢复指定会话和继续最近会话不能同时启用"
|
||
)
|
||
if new_session_id.strip():
|
||
raw_session_id = new_session_id.strip()
|
||
try:
|
||
normalized_session_id = str(uuid.UUID(raw_session_id))
|
||
except (ValueError, AttributeError) as exc:
|
||
raise GrokBuildError("新会话 ID 必须是有效 UUID") from exc
|
||
if raw_session_id != normalized_session_id:
|
||
raise GrokBuildError("新会话 ID 必须是规范的小写 UUID")
|
||
args.extend(["--session-id", normalized_session_id])
|
||
elif resume_session.strip():
|
||
args.extend(["--resume", resume_session.strip()])
|
||
elif continue_session:
|
||
args.append("--continue")
|
||
if sandbox.strip():
|
||
args.extend(["--sandbox", sandbox.strip()])
|
||
if rules.strip():
|
||
args.extend(["--rules", rules.strip()])
|
||
return args
|
||
|
||
def build_acp_args(
|
||
self,
|
||
*,
|
||
model: str = "",
|
||
yolo: bool = False,
|
||
sandbox: str = "",
|
||
) -> list[str]:
|
||
args: list[str] = ["--no-auto-update"]
|
||
if self._agent_uses_dify_source():
|
||
args.append("--disable-web-search")
|
||
if sandbox.strip():
|
||
args.extend(["--sandbox", sandbox.strip()])
|
||
args.extend(["agent", "--no-leader"])
|
||
if model.strip():
|
||
args.extend(["--model", model.strip()])
|
||
if yolo:
|
||
args.append("--always-approve")
|
||
args.append("stdio")
|
||
return args
|
||
|
||
def run_capture(
|
||
self,
|
||
args: Sequence[str],
|
||
*,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
timeout: float = 60.0,
|
||
include_managed_secrets: bool = False,
|
||
) -> subprocess.CompletedProcess[str]:
|
||
binary = self.require_binary()
|
||
if include_managed_secrets:
|
||
self.prepare_agent_configuration()
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
return subprocess.run(
|
||
[str(binary), *args],
|
||
cwd=str(selected_workspace),
|
||
env=self.runtime_environment(
|
||
include_model_key=include_managed_secrets,
|
||
include_mcp_secrets=include_managed_secrets,
|
||
workspace=selected_workspace,
|
||
custom_model_only=True,
|
||
),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=timeout,
|
||
check=False,
|
||
)
|
||
|
||
def verify_read_only_environment(
|
||
self,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
) -> None:
|
||
"""Refuse read-only mode when native executable extensions are active.
|
||
|
||
Upstream starts configured MCP servers, plugin hooks, and LSP servers
|
||
outside the built-in tool allow-list. A metadata-only inspect runs
|
||
first without managed secrets; the model process is not started unless
|
||
the effective configuration is free of those executable surfaces.
|
||
"""
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
if not selected_workspace.is_dir():
|
||
raise GrokBuildError(f"工作目录不存在:{selected_workspace}")
|
||
completed = self.run_capture(
|
||
["--no-auto-update", "inspect", "--json"],
|
||
workspace=selected_workspace,
|
||
timeout=30,
|
||
include_managed_secrets=False,
|
||
)
|
||
if completed.returncode != 0:
|
||
raise GrokBuildError(
|
||
"无法验证只读环境,Grok inspect 退出码为 "
|
||
f"{completed.returncode}:{(completed.stdout or '').strip()}"
|
||
)
|
||
try:
|
||
inspection = json.loads((completed.stdout or "").lstrip("\ufeff"))
|
||
except json.JSONDecodeError as exc:
|
||
raise GrokBuildError(
|
||
"无法验证只读环境:Grok inspect 未返回有效 JSON"
|
||
) from exc
|
||
if not isinstance(inspection, dict):
|
||
raise GrokBuildError("无法验证只读环境:Grok inspect 返回结构无效")
|
||
|
||
risks: list[str] = []
|
||
hooks = inspection.get("hooks", [])
|
||
if not isinstance(hooks, list):
|
||
raise GrokBuildError("无法验证只读环境:hooks 检查结果结构无效")
|
||
for hook in hooks:
|
||
if not isinstance(hook, dict):
|
||
risks.append("Hook(未知来源)")
|
||
continue
|
||
source = hook.get("source")
|
||
plugin_name = (
|
||
str(source.get("plugin_name") or "").strip()
|
||
if isinstance(source, dict)
|
||
else ""
|
||
)
|
||
target = str(hook.get("target") or "").strip()
|
||
risks.append(f"Hook {plugin_name or target or '未知来源'}")
|
||
|
||
plugins = inspection.get("plugins", [])
|
||
if not isinstance(plugins, list):
|
||
raise GrokBuildError("无法验证只读环境:plugins 检查结果结构无效")
|
||
for plugin in plugins:
|
||
if not isinstance(plugin, dict) or plugin.get("enabled") is not True:
|
||
continue
|
||
provides = plugin.get("provides")
|
||
executable = not isinstance(provides, dict) or bool(
|
||
provides.get("hooks")
|
||
or provides.get("mcpServers")
|
||
or provides.get("lspServers")
|
||
)
|
||
if executable:
|
||
risks.append(
|
||
f"插件 {str(plugin.get('name') or '未知名称').strip()}"
|
||
)
|
||
|
||
mcp_servers = inspection.get("mcpServers", [])
|
||
if not isinstance(mcp_servers, list):
|
||
raise GrokBuildError("无法验证只读环境:mcpServers 检查结果结构无效")
|
||
for server in mcp_servers:
|
||
if not isinstance(server, dict) or server.get("disabled") is not True:
|
||
name = (
|
||
str(server.get("name") or "未知名称").strip()
|
||
if isinstance(server, dict)
|
||
else "未知名称"
|
||
)
|
||
risks.append(f"MCP {name}")
|
||
|
||
lsp_servers = inspection.get("lspServers", [])
|
||
if not isinstance(lsp_servers, list):
|
||
raise GrokBuildError("无法验证只读环境:lspServers 检查结果结构无效")
|
||
for server in lsp_servers:
|
||
if not isinstance(server, dict) or server.get("disabled") is not True:
|
||
name = (
|
||
str(server.get("name") or "未知名称").strip()
|
||
if isinstance(server, dict)
|
||
else "未知名称"
|
||
)
|
||
risks.append(f"LSP {name}")
|
||
|
||
if risks:
|
||
summary = "、".join(dict.fromkeys(risks))
|
||
raise GrokBuildError(
|
||
"只读审查拒绝启动:当前 Grok 配置含可执行扩展("
|
||
f"{summary})。这些扩展可能在模型请求前启动并继承凭据;"
|
||
"请先在完整 TUI/配置中禁用后重试,或取消“只读审查”使用逐项审批。"
|
||
)
|
||
|
||
def launch_console(
|
||
self,
|
||
args: Sequence[str] = (),
|
||
*,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
include_model_key: bool = True,
|
||
include_mcp_secrets: bool = True,
|
||
custom_model_only: bool = True,
|
||
) -> subprocess.Popen:
|
||
binary = self.require_binary()
|
||
if include_model_key:
|
||
self.prepare_agent_configuration()
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
if not selected_workspace.is_dir():
|
||
raise GrokBuildError(f"工作目录不存在:{selected_workspace}")
|
||
kwargs: dict[str, object] = {
|
||
"cwd": str(selected_workspace),
|
||
"env": self.runtime_environment(
|
||
include_model_key=include_model_key,
|
||
include_mcp_secrets=include_mcp_secrets,
|
||
workspace=selected_workspace,
|
||
custom_model_only=custom_model_only,
|
||
),
|
||
}
|
||
if os.name == "nt":
|
||
kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
|
||
forwarded = list(args)
|
||
if "--no-auto-update" not in forwarded:
|
||
forwarded.insert(0, "--no-auto-update")
|
||
return subprocess.Popen([str(binary), *forwarded], **kwargs)
|
||
|
||
def open_tui(
|
||
self,
|
||
*,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
initial_prompt: str = "",
|
||
model: str = "",
|
||
) -> subprocess.Popen:
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
requested_model = model.strip()
|
||
if requested_model and requested_model != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"本项目的 Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
args = [
|
||
"--cwd",
|
||
str(selected_workspace),
|
||
"--model",
|
||
MODEL_PROFILE,
|
||
]
|
||
if self._agent_uses_dify_source():
|
||
args.insert(0, "--disable-web-search")
|
||
if initial_prompt.strip():
|
||
args.append(initial_prompt.strip())
|
||
return self.launch_console(
|
||
args,
|
||
workspace=selected_workspace,
|
||
include_model_key=True,
|
||
include_mcp_secrets=True,
|
||
custom_model_only=True,
|
||
)
|
||
|
||
def start_acp(
|
||
self,
|
||
*,
|
||
workspace: str | os.PathLike[str] | None = None,
|
||
model: str = "",
|
||
yolo: bool = False,
|
||
sandbox: str = "",
|
||
) -> subprocess.Popen:
|
||
binary = self.require_binary()
|
||
self.prepare_agent_configuration()
|
||
selected_workspace = Path(workspace or self.project_dir).resolve()
|
||
requested_model = model.strip()
|
||
if requested_model and requested_model != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"本项目的 Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
return subprocess.Popen(
|
||
[
|
||
str(binary),
|
||
*self.build_acp_args(
|
||
model=MODEL_PROFILE,
|
||
yolo=yolo,
|
||
sandbox=sandbox,
|
||
),
|
||
],
|
||
cwd=str(selected_workspace),
|
||
env=self.runtime_environment(
|
||
include_model_key=True,
|
||
include_mcp_secrets=True,
|
||
workspace=selected_workspace,
|
||
custom_model_only=True,
|
||
),
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
)
|
||
|
||
|
||
def _status_dict(status: RuntimeStatus) -> dict:
|
||
return asdict(status)
|
||
|
||
|
||
def normalize_passthrough_args(args: Iterable[str]) -> list[str]:
|
||
"""Accept the conventional ``--`` separator without forwarding it to Grok."""
|
||
forwarded = list(args)
|
||
if forwarded[:1] == ["--"]:
|
||
forwarded = forwarded[1:]
|
||
if not forwarded:
|
||
raise GrokBuildError("exec 后必须提供 Grok 命令参数")
|
||
return forwarded
|
||
|
||
|
||
def resolve_passthrough_workspace(
|
||
args: Sequence[str],
|
||
process_workspace: str | os.PathLike[str],
|
||
) -> Path:
|
||
"""Resolve Grok's effective ``--cwd`` for configuration-layer checks."""
|
||
base = Path(process_workspace).expanduser().resolve()
|
||
selected = base
|
||
index = 0
|
||
while index < len(args):
|
||
token = str(args[index]).strip()
|
||
if token == "--cwd":
|
||
if index + 1 >= len(args) or not str(args[index + 1]).strip():
|
||
raise GrokBuildError("Grok --cwd 缺少目录参数")
|
||
raw = Path(str(args[index + 1]).strip()).expanduser()
|
||
selected = (base / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||
index += 2
|
||
continue
|
||
if token.startswith("--cwd="):
|
||
raw_value = token.split("=", 1)[1].strip()
|
||
if not raw_value:
|
||
raise GrokBuildError("Grok --cwd 缺少目录参数")
|
||
raw = Path(raw_value).expanduser()
|
||
selected = (base / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||
index += 1
|
||
if not selected.is_dir():
|
||
raise GrokBuildError(f"工作目录不存在:{selected}")
|
||
return selected
|
||
|
||
|
||
def classify_passthrough_args(args: Sequence[str]) -> str:
|
||
"""Classify upstream CLI passthrough without guessing about new commands.
|
||
|
||
``wrap`` is discovered in a complete first pass so earlier agent flags
|
||
cannot cause arbitrary wrapped child processes to receive managed secrets.
|
||
"""
|
||
safe_commands = {
|
||
"completions",
|
||
"doctor",
|
||
"export",
|
||
"help",
|
||
"inspect",
|
||
"leader",
|
||
"login",
|
||
"logout",
|
||
"mcp",
|
||
"memory",
|
||
"models",
|
||
"plugin",
|
||
"sessions",
|
||
"setup",
|
||
"trace",
|
||
"update",
|
||
"version",
|
||
"worktree",
|
||
}
|
||
agent_commands = {"agent", "dashboard"}
|
||
value_options = {
|
||
"--agent",
|
||
"--agents",
|
||
"--allow",
|
||
"--cwd",
|
||
"--debug-file",
|
||
"--deny",
|
||
"--disallowed-tools",
|
||
"--json-schema",
|
||
"--leader-socket",
|
||
"-m",
|
||
"--model",
|
||
"--max-turns",
|
||
"--output-format",
|
||
"--permission-mode",
|
||
"--reasoning-effort",
|
||
"--rules",
|
||
"-s",
|
||
"--session-id",
|
||
"--sandbox",
|
||
"--system-prompt-override",
|
||
"--tools",
|
||
"--worktree-ref",
|
||
}
|
||
required_agent_values = {
|
||
"-p",
|
||
"--single",
|
||
"--prompt-file",
|
||
"--prompt-json",
|
||
}
|
||
optional_agent_values = {"-r", "--resume", "-w", "--worktree"}
|
||
boolean_agent_flags = {"-c", "--continue"}
|
||
if not args:
|
||
return "unknown"
|
||
|
||
# Security priority pass: find a real top-level wrap token while skipping
|
||
# values belonging to options. False positives for optional resume or
|
||
# worktree values are intentionally treated as wrap (no secrets).
|
||
index = 0
|
||
value_taking = value_options | required_agent_values
|
||
while index < len(args):
|
||
token = str(args[index]).strip()
|
||
lowered = token.lower()
|
||
if (
|
||
lowered in {"--plugin-dir", "--agent-profile"}
|
||
or lowered.startswith("--plugin-dir=")
|
||
or lowered.startswith("--agent-profile=")
|
||
):
|
||
return "extension"
|
||
if lowered in value_taking:
|
||
index += 2
|
||
continue
|
||
if any(
|
||
lowered.startswith(option + "=")
|
||
for option in value_taking
|
||
if option.startswith("--")
|
||
) or lowered.startswith("-p="):
|
||
index += 1
|
||
continue
|
||
if lowered == "wrap":
|
||
return "wrap"
|
||
index += 1
|
||
|
||
index = 0
|
||
agent_requested = False
|
||
while index < len(args):
|
||
token = str(args[index]).strip()
|
||
lowered = token.lower()
|
||
if lowered in {"--help", "-h", "--version", "-v"}:
|
||
return "safe"
|
||
if lowered in required_agent_values:
|
||
agent_requested = True
|
||
index += 2
|
||
continue
|
||
if any(
|
||
lowered.startswith(flag + "=")
|
||
for flag in required_agent_values
|
||
if flag.startswith("--")
|
||
) or lowered.startswith("-p="):
|
||
agent_requested = True
|
||
index += 1
|
||
continue
|
||
if lowered in optional_agent_values:
|
||
agent_requested = True
|
||
if (
|
||
index + 1 < len(args)
|
||
and not str(args[index + 1]).startswith("-")
|
||
and str(args[index + 1]).strip().lower()
|
||
not in safe_commands | agent_commands | {"wrap"}
|
||
):
|
||
index += 2
|
||
else:
|
||
index += 1
|
||
continue
|
||
if any(
|
||
lowered.startswith(flag + "=")
|
||
for flag in optional_agent_values
|
||
if flag.startswith("--")
|
||
):
|
||
agent_requested = True
|
||
index += 1
|
||
continue
|
||
if lowered in boolean_agent_flags:
|
||
agent_requested = True
|
||
index += 1
|
||
continue
|
||
if lowered in value_options:
|
||
index += 2
|
||
continue
|
||
if any(
|
||
lowered.startswith(option + "=")
|
||
for option in value_options
|
||
if option.startswith("--")
|
||
):
|
||
index += 1
|
||
continue
|
||
if lowered.startswith("-"):
|
||
index += 1
|
||
continue
|
||
if lowered == "wrap":
|
||
return "wrap"
|
||
if lowered in safe_commands:
|
||
return "safe"
|
||
if lowered in agent_commands:
|
||
return "agent"
|
||
return "unknown"
|
||
return "agent" if agent_requested else "unknown"
|
||
|
||
|
||
def enforce_passthrough_agent_model(args: Sequence[str]) -> list[str]:
|
||
"""Pin passthrough Agent invocations to the backend-managed profile."""
|
||
forwarded = list(args)
|
||
found_model = False
|
||
index = 0
|
||
while index < len(forwarded):
|
||
token = str(forwarded[index]).strip()
|
||
lowered = token.lower()
|
||
if lowered in {"-m", "--model"}:
|
||
if index + 1 >= len(forwarded):
|
||
raise GrokBuildError(f"{token} 缺少模型参数")
|
||
requested = str(forwarded[index + 1]).strip()
|
||
if requested != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
found_model = True
|
||
index += 2
|
||
continue
|
||
if lowered.startswith("--model="):
|
||
requested = token.split("=", 1)[1].strip()
|
||
if requested != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
found_model = True
|
||
index += 1
|
||
if not found_model:
|
||
forwarded[0:0] = ["--model", MODEL_PROFILE]
|
||
return forwarded
|
||
|
||
|
||
def reject_passthrough_model_bypasses(args: Sequence[str]) -> None:
|
||
"""Reject high-priority CLI surfaces that can bypass managed routing."""
|
||
profile_options = {"--agent", "--agents", "--agent-profile", "--plugin-dir"}
|
||
model_options = {
|
||
"-m",
|
||
"--model",
|
||
"--web-search-model",
|
||
"--session-summary-model",
|
||
"--image-description-model",
|
||
"--prompt-suggestions-model",
|
||
}
|
||
index = 0
|
||
while index < len(args):
|
||
token = str(args[index]).strip()
|
||
lowered = token.lower()
|
||
option_name = lowered.split("=", 1)[0]
|
||
if option_name in profile_options:
|
||
raise GrokBuildError(
|
||
f"{option_name} 可加载带独立模型的 Agent 配置,"
|
||
"禁止在受管密钥模式下使用"
|
||
)
|
||
if option_name in model_options:
|
||
if "=" in token:
|
||
requested = token.split("=", 1)[1].strip()
|
||
index += 1
|
||
else:
|
||
if index + 1 >= len(args):
|
||
raise GrokBuildError(f"{token} 缺少模型参数")
|
||
requested = str(args[index + 1]).strip()
|
||
index += 2
|
||
if requested != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"{option_name} 只允许后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
continue
|
||
index += 1
|
||
|
||
|
||
def _build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
description="当前项目的 Grok Build 运行时、模型同步和启动入口"
|
||
)
|
||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||
subparsers.add_parser("status", help="输出运行时和模型同步状态")
|
||
|
||
install = subparsers.add_parser("install", help="安装官方 Grok Build 运行时")
|
||
install.add_argument("--version", default="", help="指定版本;留空安装 stable")
|
||
|
||
sync = subparsers.add_parser(
|
||
"sync",
|
||
help="从 ai_settings.local.json 同步模型",
|
||
)
|
||
sync.add_argument(
|
||
"--include-mcp",
|
||
action="store_true",
|
||
help="同时导入已启用的客服 MCP;默认关闭以防止扩大数据权限",
|
||
)
|
||
|
||
tui = subparsers.add_parser("tui", help="打开完整原生 TUI")
|
||
tui.add_argument("--cwd", default=str(PROJECT_DIR))
|
||
tui.add_argument("--model", default="")
|
||
tui.add_argument("prompt", nargs="?", default="")
|
||
|
||
login = subparsers.add_parser(
|
||
"login",
|
||
help="已禁用:本项目只使用后台自有模型,不建立 xAI 登录",
|
||
)
|
||
login.add_argument("--cwd", default=str(PROJECT_DIR))
|
||
|
||
run = subparsers.add_parser("run", help="执行无头任务")
|
||
run.add_argument("prompt")
|
||
run.add_argument("--cwd", default=str(PROJECT_DIR))
|
||
run.add_argument("--model", default="")
|
||
run.add_argument("--effort", default="high")
|
||
run.add_argument("--max-turns", type=int, default=50)
|
||
run.add_argument("--yolo", action="store_true")
|
||
run.add_argument("--read-only", action="store_true")
|
||
run.add_argument("--continue", dest="continue_session", action="store_true")
|
||
run.add_argument("--resume", default="")
|
||
run.add_argument("--sandbox", default="")
|
||
run.add_argument("--tools", default="")
|
||
run.add_argument("--disallowed-tools", default="")
|
||
run.add_argument("--rules", default="")
|
||
|
||
acp = subparsers.add_parser("acp", help="启动 ACP JSON-RPC stdio 服务")
|
||
acp.add_argument("--cwd", default=str(PROJECT_DIR))
|
||
acp.add_argument("--model", default="")
|
||
acp.add_argument("--yolo", action="store_true")
|
||
acp.add_argument("--sandbox", default="")
|
||
|
||
passthrough = subparsers.add_parser("exec", help="将剩余参数原样传给 Grok")
|
||
passthrough.add_argument("--cwd", default=str(PROJECT_DIR))
|
||
passthrough.add_argument(
|
||
"--with-managed-secrets",
|
||
action="store_true",
|
||
help="未知上游命令需要后台模型/MCP 密钥时显式启用;wrap 永不允许",
|
||
)
|
||
passthrough.add_argument(
|
||
"--allow-unknown",
|
||
action="store_true",
|
||
help="允许未知上游管理命令运行,但不注入后台模型/MCP 密钥",
|
||
)
|
||
passthrough.add_argument("args", nargs=argparse.REMAINDER)
|
||
return parser
|
||
|
||
|
||
def main(argv: Iterable[str] | None = None) -> int:
|
||
args = _build_parser().parse_args(list(argv) if argv is not None else None)
|
||
manager = GrokBuildManager()
|
||
try:
|
||
if args.command == "status":
|
||
print(json.dumps(_status_dict(manager.status()), ensure_ascii=False, indent=2))
|
||
return 0
|
||
if args.command == "install":
|
||
def progress(received: int, total: int) -> None:
|
||
if total:
|
||
percent = min(100, int(received * 100 / total))
|
||
print(f"\r下载中 {percent:3d}% {received}/{total} 字节", end="", flush=True)
|
||
else:
|
||
print(f"\r下载中 {received} 字节", end="", flush=True)
|
||
|
||
status = manager.install_official_release(args.version or None, progress)
|
||
print()
|
||
print(json.dumps(_status_dict(status), ensure_ascii=False, indent=2))
|
||
return 0
|
||
if args.command == "sync":
|
||
result = manager.sync_model_configuration(include_mcp=args.include_mcp)
|
||
print(json.dumps(asdict(result), ensure_ascii=False, indent=2))
|
||
return 0 if result.compatible else 2
|
||
if args.command == "tui":
|
||
process = manager.open_tui(
|
||
workspace=args.cwd,
|
||
initial_prompt=args.prompt,
|
||
model=args.model,
|
||
)
|
||
if manager.model_profile().api_backend == "dify":
|
||
try:
|
||
return int(process.wait())
|
||
except KeyboardInterrupt:
|
||
try:
|
||
process.terminate()
|
||
return int(process.wait(timeout=3))
|
||
except (OSError, subprocess.SubprocessError):
|
||
try:
|
||
process.kill()
|
||
except OSError:
|
||
pass
|
||
return 130
|
||
return 0
|
||
if args.command == "login":
|
||
raise GrokBuildError(
|
||
"本项目已禁用 Grok/xAI 登录;Grok Build 仅作为 Agent,"
|
||
"请在后台配置自有模型"
|
||
)
|
||
if args.command == "run":
|
||
binary = manager.require_binary()
|
||
manager.prepare_agent_configuration()
|
||
requested_model = args.model.strip()
|
||
if requested_model and requested_model != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"本项目的 Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
if args.read_only:
|
||
manager.verify_read_only_environment(args.cwd)
|
||
command = manager.build_headless_args(
|
||
args.prompt,
|
||
workspace=args.cwd,
|
||
model=MODEL_PROFILE,
|
||
effort=args.effort,
|
||
max_turns=args.max_turns,
|
||
auto_approve=args.yolo,
|
||
read_only=args.read_only,
|
||
continue_session=args.continue_session,
|
||
resume_session=args.resume,
|
||
sandbox=args.sandbox,
|
||
allowed_tools=args.tools,
|
||
disallowed_tools=args.disallowed_tools,
|
||
rules=args.rules,
|
||
)
|
||
completed = subprocess.run(
|
||
[str(binary), *command],
|
||
cwd=str(Path(args.cwd).resolve()),
|
||
env=manager.runtime_environment(
|
||
include_model_key=True,
|
||
include_mcp_secrets=not args.read_only,
|
||
workspace=args.cwd,
|
||
custom_model_only=True,
|
||
),
|
||
check=False,
|
||
)
|
||
return int(completed.returncode)
|
||
if args.command == "acp":
|
||
binary = manager.require_binary()
|
||
manager.prepare_agent_configuration()
|
||
requested_model = args.model.strip()
|
||
if requested_model and requested_model != MODEL_PROFILE:
|
||
raise GrokBuildError(
|
||
f"本项目的 Grok Agent 只允许使用后台受管模型 {MODEL_PROFILE}"
|
||
)
|
||
command = manager.build_acp_args(
|
||
model=MODEL_PROFILE,
|
||
yolo=args.yolo,
|
||
sandbox=args.sandbox,
|
||
)
|
||
completed = subprocess.run(
|
||
[str(binary), *command],
|
||
cwd=str(Path(args.cwd).resolve()),
|
||
env=manager.runtime_environment(
|
||
include_model_key=True,
|
||
include_mcp_secrets=True,
|
||
workspace=args.cwd,
|
||
custom_model_only=True,
|
||
),
|
||
check=False,
|
||
)
|
||
return int(completed.returncode)
|
||
if args.command == "exec":
|
||
binary = manager.require_binary()
|
||
forwarded = normalize_passthrough_args(args.args)
|
||
process_workspace = Path(args.cwd).resolve()
|
||
effective_workspace = resolve_passthrough_workspace(
|
||
forwarded,
|
||
process_workspace,
|
||
)
|
||
passthrough_kind = classify_passthrough_args(forwarded)
|
||
if passthrough_kind == "safe" and any(
|
||
str(token).strip().lower() in {"login", "logout", "setup"}
|
||
for token in forwarded
|
||
):
|
||
raise GrokBuildError(
|
||
"本项目禁止通过 Grok 建立或修改 xAI 登录;"
|
||
"请使用后台自有模型配置"
|
||
)
|
||
if (
|
||
passthrough_kind in {"wrap", "extension"}
|
||
and args.with_managed_secrets
|
||
):
|
||
surface = (
|
||
"wrap"
|
||
if passthrough_kind == "wrap"
|
||
else "--plugin-dir/--agent-profile"
|
||
)
|
||
raise GrokBuildError(
|
||
f"{surface} 会加载任意本地代码,禁止向其注入受管密钥"
|
||
)
|
||
if (
|
||
passthrough_kind == "unknown"
|
||
and not args.with_managed_secrets
|
||
and not args.allow_unknown
|
||
):
|
||
raise GrokBuildError(
|
||
"未知 Grok 命令默认不执行,以免凭据回退或泄漏;"
|
||
"管理命令使用 exec --allow-unknown -- <参数>,"
|
||
"确认需要模型能力后使用 exec --with-managed-secrets -- <参数>"
|
||
)
|
||
include_secrets = (
|
||
passthrough_kind == "agent" or args.with_managed_secrets
|
||
) and passthrough_kind not in {"wrap", "extension"}
|
||
if include_secrets:
|
||
manager.prepare_agent_configuration()
|
||
reject_passthrough_model_bypasses(forwarded)
|
||
if passthrough_kind == "agent":
|
||
forwarded = enforce_passthrough_agent_model(forwarded)
|
||
if "--no-auto-update" not in forwarded:
|
||
forwarded.insert(0, "--no-auto-update")
|
||
completed = subprocess.run(
|
||
[str(binary), *forwarded],
|
||
cwd=str(process_workspace),
|
||
env=manager.runtime_environment(
|
||
include_model_key=include_secrets,
|
||
include_mcp_secrets=include_secrets,
|
||
workspace=effective_workspace,
|
||
custom_model_only=True,
|
||
),
|
||
check=False,
|
||
)
|
||
return int(completed.returncode)
|
||
except (GrokBuildError, OSError, subprocess.SubprocessError) as exc:
|
||
print(f"错误:{exc}", file=sys.stderr)
|
||
return 1
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|