Files
kefu/wechat_rpa/grok_build_bridge.py
2026-07-28 09:46:53 +08:00

4344 lines
168 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 上游使用明文 HTTPAPI 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())