diff --git a/CHANGELOG.md b/CHANGELOG.md
index 136ce0d..4fd89be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。
+## [0.6.3] - 2026-06-09
+
+### 亮点
+
+- **Hosted UI 联动收敛**:与最新 gateway / server 对齐 hosted `/hosted-ui/chat/`、share link、SSE 订阅和 native terminal 代理契约;`agentengine dashboard open` 继续优先打开托管入口,本地 `agentengine web` 保持调试用途。
+- **Skill Space demo 可用性修复**:补齐 `ksadk.toolsets`、Tool Gateway、Skill Runtime 和 Skill Service 相关 package 文件,安装后的 LangGraph 样例可以直接绑定 AgentEngine 内置工具。
+- **更新镜像不覆盖用户配置**:OpenClaw / Hermes 更新已有实例时默认只更新镜像和必要运行时字段,不再把本地 shell 或默认值生成的 env/storage/network/memory 配置覆盖到服务端。
+- **开源样例门禁增强**:`ksadk-samples` 主推 LangGraph demo 增加 Skill Space、Skill Runtime、Workspace、Sandbox、知识库和长期记忆配置说明,并加入敏感信息扫描与结构校验。
+
+### 修复
+
+- 修复 LangGraph runner 在工具调用后没有文本流式 chunk 时不会输出最终 answer,导致本地 Web UI 存储空 assistant message 的问题。
+- 修复 Skill Service KOP client 在 `KSADK_SKILL_SERVICE_REGION=pre-online` 下没有按 AgentEngine client 规则设置 `X-Ksc-Region: cn-beijing-6` 和 `X-KSC-CUSTOM-SOURCE: pre` 的问题。
+- 修复内置工具 dispatcher 遇到未知 include/tool name 时可能抛异常的问题,现在返回结构化 `unknown_tool` 错误,便于 Agent 继续解释。
+- 修复 OpenClaw / Hermes deploy update payload 默认携带 `env_vars`、`storage`、`network` 等配置组的问题,降低客户更新公共镜像时误改生产配置的风险。
+
+### 兼容性说明
+
+- 新建 OpenClaw / Hermes 实例仍会发送完整 env/storage/network/UI 配置;只有更新已有实例时默认改为最小 payload。
+- 更新已有 OpenClaw / Hermes 时,如需覆盖模型或环境变量,请显式传入 `--model-base-url`、`--model-api-key`、`--default-model` 或 OpenClaw 的 `--env`;如需覆盖挂盘或网络,请显式传入对应 `--storage-*` / `--enable-vpc-access` 等参数。
+- `--no-storage` 在已有实例更新场景下不会删除服务端既有挂盘配置;删除挂盘属于后续需要服务端明确 API 支持的危险操作。
+
## [0.6.2] - 2026-06-04
### 亮点
diff --git a/README.en.md b/README.en.md
index e4852f6..b7b5b64 100644
--- a/README.en.md
+++ b/README.en.md
@@ -6,7 +6,7 @@
Kingsoft Cloud Agent Development Kit. `ksadk` provides the Python SDK and command line tools for building, running, packaging, and deploying AgentEngine agents. After installation, both `agentengine` and the equivalent `ksadk` command are available. KsADK covers local development, serverless runtime, ADK, LangChain/LangGraph, DeepAgents, Hermes, OpenClaw, MCP, and Skill Runtime scenarios.
-Current version: `0.6.2`.
+Current version: `0.6.3`.
## Install
@@ -60,6 +60,14 @@ agentengine dashboard open
- Built-in AgentEngine tools: skill discovery/loading, workspace file operations, component status, sandbox status, and sandbox direct code/command execution
- Sandbox Runtime: common sandbox abstraction with an E2B-compatible backend
+## 0.6.3 Highlights
+
+- Hosted UI links align with the latest gateway / server `/hosted-ui/chat/`, share link, SSE subscription, and native terminal proxy contracts; `agentengine dashboard open` keeps using hosted entrypoints while `agentengine web` stays focused on local debugging.
+- LangGraph runner now emits the final answer after tool calls even when the model does not stream text chunks, avoiding empty assistant messages in the local Web UI.
+- Skill Service supports `KSADK_SKILL_SERVICE_REGION=pre-online` KOP routing with the expected pre-online headers.
+- OpenClaw and Hermes updates preserve existing server-side env, storage, network, and memory configuration by default, and only overwrite those groups when matching CLI options are supplied explicitly.
+- `ksadk.toolsets`, Tool Gateway, Skill Runtime, and Skill Service files are included in the package so the recommended LangGraph demo works after a clean install.
+
## 0.6.2 Highlights
- Skill Runtime can discover Skill Space entries, download and verify skill packages, load `SKILL.md`, and execute workflow-style skills through `local_process` or E2B sandbox backends.
diff --git a/README.md b/README.md
index dbc6809..1ea8b03 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
本地开发、Serverless 运行时、Google ADK、LangChain/LangGraph、
DeepAgents、Hermes、OpenClaw、MCP 和 Skill Runtime 等场景。
-当前版本:`0.6.2`。
+当前版本:`0.6.3`。
## 安装
@@ -64,6 +64,14 @@ agentengine launch . --target serverless
- AgentEngine 内置工具:skill 发现/加载、workspace 文件操作、component status、sandbox status 和 sandbox direct code/command execution
- Sandbox Runtime:通用沙箱抽象与 E2B 兼容后端
+## 0.6.3 重点
+
+- Hosted UI 与最新 gateway / server 对齐 `/hosted-ui/chat/`、share link、SSE 订阅和 native terminal 代理契约;`agentengine dashboard open` 继续优先打开托管入口,本地 `agentengine web` 保持调试用途。
+- LangGraph runner 在工具调用后即使没有文本流式 chunk,也会输出最终 answer,避免本地 Web UI 出现空 assistant message。
+- Skill Service 支持 `KSADK_SKILL_SERVICE_REGION=pre-online` 的 KOP 路由,自动设置预发所需 header。
+- OpenClaw / Hermes 更新已有实例时默认保留服务端已有 env、storage、network、memory 配置,只在显式传入对应 CLI 参数时覆盖。
+- `ksadk.toolsets`、Tool Gateway、Skill Runtime 与 Skill Service 相关文件纳入发布包,主推 LangGraph demo 可以在干净安装后直接绑定 AgentEngine 内置工具。
+
## 0.6.2 重点
- Skill Runtime 支持 Skill Space 远端发现、按需下载、`sha256` 校验、安全解压、`SKILL.md` instruction 加载,以及 `local_process` / E2B sandbox backend workflow 执行。
@@ -90,6 +98,7 @@ agentengine launch . --target serverless
- 文档:
- 仓库:
+- wiki:
- 示例仓库:
- Web UI 仓库:
- PyPI:
diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py
index 878b059..586bd68 100644
--- a/ksadk/cli/cmd_hermes.py
+++ b/ksadk/cli/cmd_hermes.py
@@ -8,6 +8,7 @@
from typing import Any, Callable, Optional
import click
+from click.core import ParameterSource
from ksadk.api import AgentEngineClient
from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref
@@ -56,7 +57,7 @@
)
-DEFAULT_HERMES_IMAGE = "ghcr.io/kingsoftcloud/hermes-agent:2026.5.16-ksadk-v1"
+DEFAULT_HERMES_IMAGE = "ghcr.io/kingsoftcloud/hermes-agent:2026.5.29.2-ksadk-v1"
DEFAULT_HERMES_CONTEXT_LENGTHS = (
("glm-5.1", "200000"),
)
@@ -65,11 +66,12 @@
)
DEFAULT_HERMES_MODEL_NAME = "glm-5.1"
DEFAULT_HERMES_PUBLIC_BASE_URL = "https://kspmas.ksyun.com/v1/"
-DEFAULT_HERMES_RUNTIME_BASE_URL = "https://kspmas.ksyun.com/v1/"
+DEFAULT_HERMES_RUNTIME_BASE_URL = DEFAULT_HERMES_PUBLIC_BASE_URL
KSPMAS_PUBLIC_BASES = (
"http://kspmas.ksyun.com",
"https://kspmas.ksyun.com",
)
+KSPMAS_INTERNAL_BASE = DEFAULT_HERMES_PUBLIC_BASE_URL.rstrip("/")
_HERMES_GLOBAL_ENV_CACHE: dict[str, str] | None = None
HERMES_RESOURCE = ResourceDescriptor(
@@ -115,6 +117,7 @@
"agentengine hermes open ar-xxxx --chat",
"agentengine hermes exec ar-xxxx -- status",
"agentengine hermes pairing ar-xxxx -- list",
+ "agentengine hermes pairing ar-xxxx -- approve wpsxiezuo ",
"agentengine hermes delete ar-xxxx",
),
missing_ref_message="未找到 Hermes Agent,请指定 Agent(--agent 或位置参数)",
@@ -122,6 +125,44 @@
)
+def _option_was_explicit(ctx: click.Context | None, name: str) -> bool:
+ if ctx is None:
+ return False
+ try:
+ return ctx.get_parameter_source(name) != ParameterSource.DEFAULT
+ except Exception:
+ return False
+
+
+def _build_hermes_update_payload(
+ *,
+ payload: dict[str, Any],
+ storage_config: dict[str, Any] | None,
+ network_payload: dict[str, Any] | None,
+ include_env: bool,
+ include_storage: bool,
+) -> dict[str, Any]:
+ """构建已有 Hermes 的最小更新请求,避免镜像更新覆盖用户配置。"""
+ update_payload: dict[str, Any] = {
+ "name": payload["name"],
+ "description": payload["description"],
+ "framework": payload["framework"],
+ "artifact_type": payload["artifact_type"],
+ "artifact_path": payload["artifact_path"],
+ "region": payload["region"],
+ "resources": payload["resources"],
+ "scaling": payload["scaling"],
+ "ui_config": payload["ui_config"],
+ }
+ if include_env:
+ update_payload["env_vars"] = payload["env_vars"]
+ if include_storage and storage_config:
+ update_payload["storage"] = storage_config
+ if network_payload:
+ update_payload["network"] = network_payload
+ return update_payload
+
+
@click.group("hermes", context_settings=CONTEXT_SETTINGS)
def hermes():
"""Hermes Agent 资源管理。"""
@@ -302,6 +343,19 @@ def _build_hermes_env_vars(
value = _env_value(*source_keys)
if value:
raw[target_key] = value
+ for key in (
+ "WPSXIEZUO_APP_ID",
+ "WPSXIEZUO_APP_KEY",
+ "WPSXIEZUO_API_BASE",
+ "WPSXIEZUO_WS_ENDPOINT",
+ "WPSXIEZUO_GROUP_AT_ONLY",
+ "WPSXIEZUO_ALLOWED_USERS",
+ "WPSXIEZUO_ALLOW_ALL_USERS",
+ "WPSXIEZUO_HOME_CHANNEL",
+ ):
+ value = _env_value(key)
+ if value:
+ raw[key] = value
return [
{"Key": key, "Value": str(value), "IsSensitive": any(token in key for token in ("KEY", "TOKEN", "SECRET"))}
for key, value in raw.items()
@@ -327,7 +381,8 @@ def _validate_hermes_model_config(
def _normalize_hermes_runtime_base_url(base_url: str | None) -> str:
- return str(base_url or "").strip()
+ normalized = str(base_url or "").strip()
+ return normalized
def _flatten_agent_detail(agent: dict[str, Any]) -> dict[str, Any]:
@@ -481,6 +536,21 @@ def deploy(
"""部署 Hermes runtime 到云端。"""
_ = output_mode
dry_run = effective_dry_run(dry_run)
+ ctx = click.get_current_context(silent=True)
+ include_env_on_update = any(
+ (
+ _option_was_explicit(ctx, "model_base_url"),
+ _option_was_explicit(ctx, "model_api_key"),
+ _option_was_explicit(ctx, "default_model"),
+ )
+ )
+ include_storage_on_update = any(
+ (
+ _option_was_explicit(ctx, "storage_size_gi"),
+ _option_was_explicit(ctx, "storage_mount_path"),
+ _option_was_explicit(ctx, "no_storage"),
+ )
+ )
run_async_with_dry_run(
_deploy_hermes(
name=name,
@@ -494,6 +564,8 @@ def deploy(
storage_size_gi=storage_size_gi,
storage_mount_path=storage_mount_path,
no_storage=no_storage,
+ include_env_on_update=include_env_on_update,
+ include_storage_on_update=include_storage_on_update,
**network_cli_kwargs(
enable_public_access=enable_public_access,
enable_vpc_access=enable_vpc_access,
@@ -523,6 +595,8 @@ async def _deploy_hermes(
storage_size_gi: int,
storage_mount_path: str | None,
no_storage: bool,
+ include_env_on_update: bool,
+ include_storage_on_update: bool,
enable_public_access: bool | None,
enable_vpc_access: bool,
vpc_id: str | None,
@@ -582,6 +656,8 @@ async def _deploy_hermes(
)
if storage_config:
payload["storage"] = storage_config
+ if existing_agent_id and include_storage_on_update and no_storage:
+ print_warn("更新已有 Hermes 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。")
network_payload = build_network_payload(
enable_public_access=enable_public_access,
enable_vpc_access=enable_vpc_access,
@@ -589,6 +665,8 @@ async def _deploy_hermes(
subnet_id=subnet_id,
security_group_id=security_group_id,
availability_zone=availability_zone,
+ region=region,
+ dry_run=dry_run,
)
if network_payload:
payload["network"] = network_payload
@@ -599,7 +677,14 @@ async def _deploy_hermes(
async with AgentEngineClient(region=region, dry_run=dry_run) as client:
if existing_agent_id:
- res = await client.update_agent(existing_agent_id, payload)
+ update_payload = _build_hermes_update_payload(
+ payload=payload,
+ storage_config=storage_config,
+ network_payload=network_payload,
+ include_env=include_env_on_update,
+ include_storage=include_storage_on_update,
+ )
+ res = await client.update_agent(existing_agent_id, update_payload)
if res is None:
res = {}
res.setdefault("agent_id", existing_agent_id)
@@ -958,7 +1043,11 @@ def pairing_hermes(
dry_run: bool,
output_mode: str | None,
):
- """透传 Hermes pairing 审批子命令。"""
+ """透传 Hermes pairing 审批子命令。
+
+ WPS 协作配对码来自未授权用户私聊机器人时 Hermes 返回的 pairing code,
+ 审批示例:agentengine hermes pairing -- approve wpsxiezuo
+ """
_ = output_mode
try:
agent_ref, validated_argv = _split_terminal_agent_ref_and_argv(
diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py
index 21d8f28..0be4376 100644
--- a/ksadk/cli/cmd_openclaw.py
+++ b/ksadk/cli/cmd_openclaw.py
@@ -29,6 +29,7 @@
from typing import Optional, Dict, Any
import click
+from click.core import ParameterSource
from rich.measure import Measurement
from rich.table import Table as RichTable
@@ -77,11 +78,11 @@
from ksadk.terminal_client import run_terminal_session
console = get_console()
-# 默认 OpenClaw 镜像 (KCR 个人版)
+# 默认 OpenClaw 镜像。
DEFAULT_OPENCLAW_NAMESPACE = "agentengine-public"
DEFAULT_OPENCLAW_REPO = "openclaw"
-DEFAULT_OPENCLAW_VERSION = "2026.5.22"
-DEFAULT_OPENCLAW_REGISTRY = "ghcr.io"
+DEFAULT_OPENCLAW_VERSION = "2026.6.1"
+DEFAULT_OPENCLAW_REGISTRY = "ghcr.io/kingsoftcloud"
DEFAULT_OPENCLAW_NAME = "openclaw-gateway"
DEFAULT_TRUSTED_PROXY_USER_HEADER = "x-forwarded-user"
DEFAULT_TRUSTED_PROXY_CIDRS = [
@@ -245,6 +246,49 @@
)
+def _option_was_explicit(ctx: click.Context | None, name: str) -> bool:
+ if ctx is None:
+ return False
+ try:
+ return ctx.get_parameter_source(name) != ParameterSource.DEFAULT
+ except Exception:
+ return False
+
+
+def _build_openclaw_update_payload(
+ *,
+ image_ref: str,
+ cpu: str,
+ memory: str,
+ env_list: list[dict[str, Any]],
+ memory_config: dict[str, Any] | None,
+ image_credential: dict[str, Any] | None,
+ storage_config: dict[str, Any] | None,
+ network_payload: dict[str, Any] | None,
+ include_env: bool,
+ include_storage: bool,
+):
+ """构建已有 OpenClaw 的最小更新请求,默认保留服务端现有配置。"""
+ update_payload: dict[str, Any] = {
+ "artifact_type": "Container",
+ "artifact_path": image_ref,
+ "resources": {"cpu": cpu, "memory": memory},
+ "auth_type": "None",
+ "inbound_identity_auth": "None",
+ }
+ if include_env:
+ update_payload["env_vars"] = env_list
+ if memory_config:
+ update_payload["memory_config"] = memory_config
+ if image_credential:
+ update_payload["image_credential"] = image_credential
+ if include_storage and storage_config:
+ update_payload["storage"] = storage_config
+ if network_payload:
+ update_payload["network"] = network_payload
+ return update_payload
+
+
def _abort_openclaw_error(
err: Exception,
*,
@@ -655,7 +699,7 @@ def _build_openclaw_env_vars(
env = {}
default_provider_id = "ksyun"
default_model_api = "openai-completions"
- default_model_base_url = "https://kspmas.ksyun.com/v1/"
+ default_model_base_url = "https://kspmas.ksyun.com/v1"
exec_profile_overrides = _resolve_exec_profile_overrides(security_profile)
# 模型配置:客户端只透传用户显式配置和可选的 API Key;
@@ -1011,14 +1055,14 @@ def _parse_image(image: Optional[str]) -> tuple[str, str, str]:
"""解析镜像地址为 (namespace, repo, version)
支持格式:
- - ghcr.io/ns/repo:tag → (ns, repo, tag)
+ - ghcr.io/kingsoftcloud/ns/repo:tag → (ns, repo, tag)
- ns/repo:tag → (ns, repo, tag)
- 无输入 → 使用默认值
"""
if not image:
return DEFAULT_OPENCLAW_NAMESPACE, DEFAULT_OPENCLAW_REPO, DEFAULT_OPENCLAW_VERSION
- # 去掉 registry 域名前缀 (ghcr.io/)
+ # 去掉 registry 域名前缀。
path = image
if "/" in path:
parts = path.split("/")
@@ -3137,11 +3181,28 @@ def deploy(
# 显式指定模型
agentengine openclaw deploy --model-base-url https://api.example.com/v1 --model-api-key sk-xxx
# 使用自定义镜像
- agentengine openclaw deploy --image ghcr.io/myns/openclaw:v2
+ agentengine openclaw deploy --image ghcr.io/my-org/openclaw:v2
# 透传业务自定义环境变量
agentengine openclaw deploy --env APP_MODE=prod --env API_BASE=https://example.com
"""
dry_run = effective_dry_run(dry_run)
+ ctx = click.get_current_context(silent=True)
+ include_env_on_update = any(
+ (
+ _option_was_explicit(ctx, "security_profile"),
+ _option_was_explicit(ctx, "model_base_url"),
+ _option_was_explicit(ctx, "model_api_key"),
+ _option_was_explicit(ctx, "default_model"),
+ bool(extra_env),
+ )
+ )
+ include_storage_on_update = any(
+ (
+ _option_was_explicit(ctx, "storage_size_gi"),
+ _option_was_explicit(ctx, "storage_mount_path"),
+ _option_was_explicit(ctx, "no_storage"),
+ )
+ )
_build_openclaw_memory_config(
memory_system=memory_system,
mem0_instance_id=mem0_instance_id,
@@ -3166,6 +3227,8 @@ def deploy(
storage_size_gi=storage_size_gi,
storage_mount_path=storage_mount_path,
no_storage=no_storage,
+ include_env_on_update=include_env_on_update,
+ include_storage_on_update=include_storage_on_update,
**network_cli_kwargs(
enable_public_access=enable_public_access,
enable_vpc_access=enable_vpc_access,
@@ -3199,6 +3262,8 @@ async def _deploy_openclaw(
storage_size_gi: int = 20,
storage_mount_path: Optional[str] = None,
no_storage: bool = False,
+ include_env_on_update: bool = False,
+ include_storage_on_update: bool = False,
enable_public_access: Optional[bool] = None,
enable_vpc_access: bool = False,
vpc_id: Optional[str] = None,
@@ -3336,6 +3401,8 @@ async def _deploy_openclaw(
)
if storage_config:
request_data["storage"] = storage_config
+ if existing_agent_id and include_storage_on_update and no_storage:
+ print_warn("更新已有 OpenClaw 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。")
network_payload = build_network_payload(
enable_public_access=enable_public_access,
enable_vpc_access=enable_vpc_access,
@@ -3343,6 +3410,8 @@ async def _deploy_openclaw(
subnet_id=subnet_id,
security_group_id=security_group_id,
availability_zone=availability_zone,
+ region=region,
+ dry_run=dry_run,
)
if network_payload:
request_data["network"] = network_payload
@@ -3366,22 +3435,18 @@ async def _deploy_openclaw(
if dry_run:
async with AgentEngineClient(region=region, dry_run=True) as client:
if existing_agent_id:
- update_payload = {
- "artifact_type": "Container",
- "artifact_path": image_ref,
- "resources": {"cpu": cpu, "memory": memory},
- "env_vars": env_list,
- "auth_type": "None",
- "inbound_identity_auth": "None",
- }
- if memory_config:
- update_payload["memory_config"] = memory_config
- if image_credential:
- update_payload["image_credential"] = image_credential
- if storage_config:
- update_payload["storage"] = storage_config
- if network_payload:
- update_payload["network"] = network_payload
+ update_payload = _build_openclaw_update_payload(
+ image_ref=image_ref,
+ cpu=cpu,
+ memory=memory,
+ env_list=env_list,
+ memory_config=memory_config,
+ image_credential=image_credential,
+ storage_config=storage_config,
+ network_payload=network_payload,
+ include_env=include_env_on_update,
+ include_storage=include_storage_on_update,
+ )
await client.update_agent(existing_agent_id, update_payload)
else:
await client.create_agent(request_data)
@@ -3396,22 +3461,18 @@ async def _deploy_openclaw(
if existing_agent_id:
print_info(f"检测到本地状态: {existing_agent_id},执行更新...")
try:
- update_payload = {
- "artifact_type": "Container",
- "artifact_path": image_ref,
- "resources": {"cpu": cpu, "memory": memory},
- "env_vars": env_list,
- "auth_type": "None",
- "inbound_identity_auth": "None",
- }
- if memory_config:
- update_payload["memory_config"] = memory_config
- if image_credential:
- update_payload["image_credential"] = image_credential
- if storage_config:
- update_payload["storage"] = storage_config
- if network_payload:
- update_payload["network"] = network_payload
+ update_payload = _build_openclaw_update_payload(
+ image_ref=image_ref,
+ cpu=cpu,
+ memory=memory,
+ env_list=env_list,
+ memory_config=memory_config,
+ image_credential=image_credential,
+ storage_config=storage_config,
+ network_payload=network_payload,
+ include_env=include_env_on_update,
+ include_storage=include_storage_on_update,
+ )
res = await client.update_agent(existing_agent_id, update_payload)
agent_id = existing_agent_id
endpoint = res.get("endpoint") or state.get("endpoint")
diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py
index e8b6a8a..8cbd5e1 100644
--- a/ksadk/configs/env_registry.py
+++ b/ksadk/configs/env_registry.py
@@ -16,6 +16,7 @@ class EnvVarSpec:
EnvVarSpec("KSADK_ADK_SESSION_BACKEND", "sessions", "ADK-native session backend selector."),
EnvVarSpec("KSADK_ADK_SESSION_PATH", "sessions", "ADK-native SQLite session database path."),
EnvVarSpec("KSADK_ADK_SESSION_URL", "sessions", "ADK-native database session URL.", sensitive=True),
+ EnvVarSpec("KSADK_AICP_ENDPOINT_MODE", "platform", "AICP endpoint selection mode: auto, detect, inner, or public."),
EnvVarSpec("KSADK_ALLOWED_SUFFIXES", "builders", "Internal code package allowed suffix constant."),
EnvVarSpec(
"KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS",
@@ -39,18 +40,18 @@ class EnvVarSpec:
"Include LangChain MCP adapter dependencies in source/container builds.",
"false",
),
- EnvVarSpec(
- "KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS",
- "builders",
- "pip install timeout seconds for source builds.",
- "2700",
- ),
EnvVarSpec(
"KSADK_BUILD_ENABLE_POSTGRES_SESSION",
"builders",
"Include asyncpg dependency for PostgreSQL session backend in source/container builds.",
"false",
),
+ EnvVarSpec(
+ "KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS",
+ "builders",
+ "pip install timeout seconds for source builds.",
+ "2700",
+ ),
EnvVarSpec(
"KSADK_CORE_RUNTIME_REQUIREMENTS",
"builders",
@@ -92,6 +93,11 @@ class EnvVarSpec:
EnvVarSpec("KSADK_LTM_SCHEME", "memory", "Long-term-memory API scheme.", "https"),
EnvVarSpec("KSADK_LTM_SECRET_KEY", "memory", "Long-term-memory API secret key.", sensitive=True),
EnvVarSpec("KSADK_LTM_TOP_K", "memory", "Long-term-memory retrieval result count.", "5"),
+ EnvVarSpec(
+ "KSADK_MCP_RUNTIME_REQUIREMENTS",
+ "builders",
+ "Internal bundled MCP adapter runtime requirement constant.",
+ ),
EnvVarSpec("KSADK_MCP_SERVERS", "mcp_runtime", "JSON array of MCP server configs.", sensitive=True),
EnvVarSpec("KSADK_MEMORY_BACKEND", "memory", "Generic memory backend selector.", "memory"),
EnvVarSpec("KSADK_MEMORY_PREFIX", "memory", "Generic memory key prefix.", "ksadk:memory:"),
@@ -100,6 +106,11 @@ class EnvVarSpec:
EnvVarSpec("KSADK_PG_EVENTS_TABLE", "sessions", "Internal PostgreSQL events table constant."),
EnvVarSpec("KSADK_PG_SESSIONS_TABLE", "sessions", "Internal PostgreSQL sessions table constant."),
EnvVarSpec("KSADK_PG_STATES_TABLE", "sessions", "Internal PostgreSQL states table constant."),
+ EnvVarSpec(
+ "KSADK_POSTGRES_SESSION_REQUIREMENTS",
+ "builders",
+ "Internal bundled PostgreSQL session runtime requirement constant.",
+ ),
EnvVarSpec("KSADK_PROJECT_DIR", "sessions", "Project root used for local session/workspace state."),
EnvVarSpec("KSADK_PUBLIC_SKILL_ALLOWLIST", "skills", "Comma-separated public Skill names to load; empty loads all public skills."),
EnvVarSpec("KSADK_PUBLIC_SKILL_SPACE_IDS", "skills", "Comma-separated public Skill Space ids appended after user spaces."),
@@ -128,15 +139,20 @@ class EnvVarSpec:
EnvVarSpec("KSADK_SKILL_CACHE_DIR", "skills", "Skill package download and extraction cache directory."),
EnvVarSpec("KSADK_SKILL_MANIFEST_LIMIT", "skills", "Maximum remote Skill manifests injected into agent instructions.", "30"),
EnvVarSpec("KSADK_SKILL_MANIFEST_TIMEOUT", "skills", "Remote Skill manifest listing timeout seconds.", "5"),
+ EnvVarSpec("KSADK_SKILL_OUTPUT_DIR", "skills", "Output directory exposed to local Skill workflow scripts."),
+ EnvVarSpec("KSADK_SKILL_ROOT_DIR", "skills", "Root directory of the Skill currently executed by the local workflow runner."),
EnvVarSpec("KSADK_SKILL_RUNTIME_AGENT_PATH", "skills", "Local process Skill Runtime agent path."),
EnvVarSpec("KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS", "skills", "Allow remote Skill Runtime internet access.", "true"),
EnvVarSpec("KSADK_SKILL_RUNTIME_BACKEND", "skills", "Skill Runtime backend selector.", "disabled"),
EnvVarSpec("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "skills", "Skill Runtime backend template id."),
EnvVarSpec("KSADK_SKILL_RUNTIME_TIMEOUT", "skills", "Skill Runtime workflow timeout seconds.", "900"),
+ EnvVarSpec("KSADK_SKILL_SERVICE", "skills", "Skill Service AICP connection environment prefix."),
EnvVarSpec("KSADK_SKILL_SERVICE_ACCESS_KEY", "skills", "Skill Service KOP access key.", sensitive=True),
EnvVarSpec("KSADK_SKILL_SERVICE_ACCOUNT_ID", "skills", "Skill Service tenant account id."),
EnvVarSpec("KSADK_SKILL_SERVICE_API_VERSION", "skills", "Skill Service KOP API version.", "2024-06-12"),
+ EnvVarSpec("KSADK_SKILL_SERVICE_ENDPOINT", "skills", "Skill Service AICP endpoint override."),
EnvVarSpec("KSADK_SKILL_SERVICE_REGION", "skills", "Skill Service KOP region.", "cn-beijing-6"),
+ EnvVarSpec("KSADK_SKILL_SERVICE_SCHEME", "skills", "Skill Service AICP URL scheme override."),
EnvVarSpec("KSADK_SKILL_SERVICE_SECRET_KEY", "skills", "Skill Service KOP secret key.", sensitive=True),
EnvVarSpec("KSADK_SKILL_SERVICE_SIGN_SERVICE", "skills", "Skill Service KOP signing service.", "aicp"),
EnvVarSpec("KSADK_SKILL_SERVICE_TOKEN", "skills", "Skill Service bearer token.", sensitive=True),
@@ -150,8 +166,14 @@ class EnvVarSpec:
EnvVarSpec("KSADK_STM_PATH", "sessions", "Short-term-memory SQLite path."),
EnvVarSpec("KSADK_STM_URL", "sessions", "Short-term-memory database URL.", sensitive=True),
EnvVarSpec("KSADK_TENANT_ID", "sessions", "Tenant id used for session namespace scoping."),
+ EnvVarSpec("KSADK_TOOL_APPROVAL_MODE", "tools", "Built-in tool approval mode: off or strict.", "off"),
EnvVarSpec("KSADK_UPDATED_AT", "configs", "Internal config update timestamp field."),
EnvVarSpec("KSADK_VERSION", "configs", "Internal config version field."),
+ EnvVarSpec("KSADK_WEB_CACHE_DIR", "web", "Directory used by hosted Web UI static asset sync cache."),
+ EnvVarSpec("KSADK_WEB_RELEASE_URL", "web", "Hosted Web UI release tarball URL."),
+ EnvVarSpec("KSADK_WEB_TARBALL_NAME", "web", "Hosted Web UI release tarball file name."),
+ EnvVarSpec("KSADK_WEB_VERSION", "web", "Hosted Web UI release version tag.", "v0.2.0"),
+ EnvVarSpec("KSADK_WORKFLOW_PROMPT", "skills", "Prompt text exposed to local Skill workflow scripts."),
EnvVarSpec("KSADK_WORKSPACE_ID", "sessions", "Workspace id used for session namespace scoping."),
EnvVarSpec("OTEL_EXPORTER_OTLP_ENDPOINT", "tracing", "Generic OTLP HTTP endpoint used to derive the traces endpoint."),
EnvVarSpec("OTEL_EXPORTER_OTLP_HEADERS", "tracing", "Generic OTLP HTTP headers, comma-separated and URL-encoded.", sensitive=True),
diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py
index 086f156..8573c5d 100644
--- a/ksadk/runners/langgraph_runner.py
+++ b/ksadk/runners/langgraph_runner.py
@@ -344,6 +344,9 @@ def _extract_output(self, result: Any) -> str:
# 自定义 output 字段是业务显式出参,优先于内部 messages state。
if "output" in result:
return result["output"]
+ # LangGraph 示例常用 answer 作为最终业务回答字段。
+ if "answer" in result:
+ return result["answer"]
# 标准 messages 格式
if "messages" in result:
messages = result["messages"]
@@ -393,6 +396,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An
accumulated_text = ""
accumulated_reasoning = ""
emitted_non_text_event = False
+ final_output_text = ""
if not hasattr(self._agent, "astream_events"):
result = await self.invoke(invoke_payload)
@@ -459,6 +463,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An
emitted_non_text_event = True
yield {"type": "interrupt", "interrupt_info": output["__interrupt__"], "session_id": session_id}
return
+ extracted_output = self._extract_output(output)
+ if extracted_output:
+ final_output_text = str(extracted_output)
except Exception as e:
if "Interrupt" in type(e).__name__:
@@ -466,9 +473,12 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An
return
raise
- if not accumulated_text and not emitted_non_text_event:
- result = await self.invoke(invoke_payload)
- yield {"output": result.get("output", ""), "type": "final"}
+ if not accumulated_text:
+ if final_output_text:
+ yield {"output": final_output_text, "type": "final"}
+ elif not emitted_non_text_event:
+ result = await self.invoke(invoke_payload)
+ yield {"output": result.get("output", ""), "type": "final"}
def _filter_tool_tags(self, content: str) -> str:
"""过滤 标签"""
diff --git a/ksadk/skills/service_client.py b/ksadk/skills/service_client.py
index ee1aa28..b1720c2 100644
--- a/ksadk/skills/service_client.py
+++ b/ksadk/skills/service_client.py
@@ -34,7 +34,9 @@ def __init__(
self.access_key = access_key or _env("KSADK_SKILL_SERVICE_ACCESS_KEY", "KSYUN_ACCESS_KEY", "KS3_ACCESS_KEY")
self.secret_key = secret_key or _env("KSADK_SKILL_SERVICE_SECRET_KEY", "KSYUN_SECRET_KEY", "KS3_SECRET_KEY")
self.account_id = account_id or _env("KSADK_SKILL_SERVICE_ACCOUNT_ID", "KSYUN_ACCOUNT_ID")
- self.region = region or _env("KSADK_SKILL_SERVICE_REGION", "KSYUN_REGION") or "cn-beijing-6"
+ self.logical_region = region or _env("KSADK_SKILL_SERVICE_REGION", "KSYUN_REGION") or "cn-beijing-6"
+ self.region = _normalize_control_region(self.logical_region)
+ self.custom_source = _resolve_custom_source(self.logical_region)
self.api_version = api_version or os.environ.get("KSADK_SKILL_SERVICE_API_VERSION", "2024-06-12")
self.sign_service = sign_service or os.environ.get("KSADK_SKILL_SERVICE_SIGN_SERVICE", "aicp")
self.extra_headers = dict(extra_headers or {})
@@ -145,6 +147,8 @@ def _headers(self, *, action: str = "") -> dict[str, str]:
"X-Ksc-Source": "ksadk-skill-runtime",
}
)
+ if self.custom_source:
+ headers["X-KSC-CUSTOM-SOURCE"] = self.custom_source
if action:
headers["X-Action"] = action
headers["X-Version"] = self.api_version
@@ -212,6 +216,19 @@ def _env(*names: str) -> str:
return ""
+def _normalize_control_region(region: str) -> str:
+ region = (region or "cn-beijing-6").strip()
+ if region.lower() == "pre-online":
+ return os.environ.get("AGENTENGINE_PRE_CONTROL_REGION", "cn-beijing-6")
+ return region
+
+
+def _resolve_custom_source(region: str) -> str:
+ if (region or "").strip().lower() == "pre-online":
+ return os.environ.get("AGENTENGINE_PRE_CUSTOM_SOURCE", "pre")
+ return ""
+
+
def _normalize_base_url(base_url: str) -> str:
parsed = urlsplit(base_url.strip())
path = parsed.path.rstrip("/")
diff --git a/ksadk/toolsets/__init__.py b/ksadk/toolsets/__init__.py
index 6606aa3..73331f1 100644
--- a/ksadk/toolsets/__init__.py
+++ b/ksadk/toolsets/__init__.py
@@ -144,7 +144,10 @@ def agentengine_tool_dispatcher(
requested_include = _normalize_include(include)
if normalized_action == "list":
- _, specs = _select_agentengine_tools(include=requested_include or _DEFAULT_GROUPS, include_dispatcher=False)
+ try:
+ _, specs = _select_agentengine_tools(include=requested_include or _DEFAULT_GROUPS, include_dispatcher=False)
+ except ValueError:
+ return _unknown_tool_error(", ".join(requested_include) if requested_include else str(include or ""))
return {"ok": True, "tools": specs, "tool_count": len(specs)}
if normalized_action == "describe":
diff --git a/ksadk/version.py b/ksadk/version.py
index 9e64881..8ca3610 100644
--- a/ksadk/version.py
+++ b/ksadk/version.py
@@ -1,4 +1,4 @@
"""KsADK 版本信息"""
-VERSION = "0.6.2"
+VERSION = "0.6.3"
__version__ = VERSION
diff --git a/pyproject.toml b/pyproject.toml
index a8a4a54..3778bda 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ksadk"
-version = "0.6.2"
+version = "0.6.3"
description = "Kingsoft Cloud Agent Development Kit - 支持 LangChain/LangGraph/DeepAgents/ADK/OpenClaw/Hermes 的本地运行与云端部署"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/tests/skills/test_service_client_http.py b/tests/skills/test_service_client_http.py
index bdc3b10..91273af 100644
--- a/tests/skills/test_service_client_http.py
+++ b/tests/skills/test_service_client_http.py
@@ -299,6 +299,44 @@ def handler(request: httpx.Request) -> httpx.Response:
assert headers["x-ksc-account-id"] == "2000003485"
+def test_service_client_routes_pre_online_kop_requests_with_custom_source(monkeypatch):
+ monkeypatch.setenv("KSADK_SKILL_SERVICE_REGION", "pre-online")
+ monkeypatch.setenv("AGENTENGINE_PRE_CONTROL_REGION", "cn-beijing-6")
+ monkeypatch.setenv("AGENTENGINE_PRE_CUSTOM_SOURCE", "pre")
+ requests = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append((request.method, str(request.url), dict(request.headers)))
+ return httpx.Response(
+ 200,
+ json={
+ "Code": 200,
+ "RequestId": "req-pre-kop",
+ "Data": {"Skills": []},
+ },
+ )
+
+ client = SkillServiceClient(
+ base_url="http://aicp.inner.api.ksyun.com",
+ account_id="73398439",
+ transport=httpx.MockTransport(handler),
+ )
+
+ listing = client.list_skills_by_space_id("ss-pre")
+
+ assert listing.space_id == "ss-pre"
+ method, url, headers = requests[0]
+ assert method == "GET"
+ assert url == (
+ "http://aicp.inner.api.ksyun.com/"
+ "?Action=ListSkillsBySpaceId&Version=2024-06-12"
+ "&SpaceId=ss-pre&PageNumber=1&PageSize=100"
+ )
+ assert headers["x-ksc-region"] == "cn-beijing-6"
+ assert headers["x-ksc-custom-source"] == "pre"
+ assert headers["x-ksc-account-id"] == "73398439"
+
+
def test_service_client_uses_registered_kop_action_for_available_premade_skills():
requests = []
diff --git a/tests/test_agentengine_toolsets.py b/tests/test_agentengine_toolsets.py
new file mode 100644
index 0000000..54eae80
--- /dev/null
+++ b/tests/test_agentengine_toolsets.py
@@ -0,0 +1,10 @@
+from ksadk.toolsets import agentengine_tool_dispatcher
+
+
+def test_dispatcher_list_returns_error_for_unknown_include_without_raising():
+ result = agentengine_tool_dispatcher(action="list", include="file")
+
+ assert result["ok"] is False
+ assert result["error_type"] == "unknown_tool"
+ assert result["tool_name"] == "file"
+
diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py
new file mode 100644
index 0000000..af24a31
--- /dev/null
+++ b/tests/test_cli_dry_run.py
@@ -0,0 +1,2597 @@
+import asyncio
+import json
+from contextlib import contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, Dict
+
+import yaml
+from click.testing import CliRunner
+
+from ksadk.api.client import AgentEngineAPIError, AgentEngineClient, DryRunExit
+from ksadk.cli import _register_commands, cli, cmd_mcp
+from ksadk.cli.cmd_agent import agent
+from ksadk.cli.cmd_destroy import delete as destroy_delete
+from ksadk.cli.cmd_destroy import destroy as destroy_cmd
+from ksadk.cli.cmd_mcp import mcp
+from ksadk.cli import cmd_openclaw
+from ksadk.cli.cmd_openclaw import openclaw
+from ksadk.cli.cmd_version import version
+from ksadk.cli.dry_run import run_async_with_dry_run
+from ksadk.deployment.base import DeployTarget
+from ksadk.deployment.providers.serverless import ServerlessProvider
+
+
+class _FakeDryRunClient:
+ last_init_kwargs: Dict[str, Any] = {}
+
+ def __init__(self, *args, **kwargs):
+ _FakeDryRunClient.last_init_kwargs = kwargs
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def list_mcps(self, **kwargs):
+ raise DryRunExit("dry-run")
+
+ async def get_mcp(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def delete_mcp(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def list_agents(self, **kwargs):
+ raise DryRunExit("dry-run")
+
+ async def get_agent(self, **kwargs):
+ raise DryRunExit("dry-run")
+
+ async def delete_agent(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def create_mcp(self, request):
+ raise DryRunExit("dry-run", payload={"body": request})
+
+ async def list_versions(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def release_version(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def rollback_version(self, *_args, **_kwargs):
+ raise DryRunExit("dry-run")
+
+ async def close(self):
+ return None
+
+
+class _FakeMCPDetectionResult:
+ is_valid = True
+ entry_point = "mcp_main.py"
+ mcp_variable = "mcp"
+ tools = ["test_tool"]
+
+
+class _FakeMCPDetector:
+ def __init__(self, *_args, **_kwargs):
+ pass
+
+ def detect(self):
+ return _FakeMCPDetectionResult()
+
+
+class _FakeOpenClawListClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def list_agents(self, **_kwargs):
+ return {
+ "agents": [
+ {
+ "agent_id": "ar-demo-1",
+ "name": "demo-openclaw",
+ "status": "running",
+ "endpoint": "https://openclaw.example.com",
+ "region": "cn-beijing-6",
+ }
+ ],
+ "total": 145,
+ }
+
+ async def close(self):
+ return None
+
+
+class _FakeOpenClawDetailClient:
+ last_log_kwargs: Dict[str, Any] = {}
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def get_agent(self, **_kwargs):
+ return {
+ "basic": {
+ "agent_id": "ar-demo-1",
+ "name": "demo-openclaw",
+ "status": "RUNNING",
+ "framework": "openclaw",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://openclaw.example.com",
+ },
+ "advanced": {
+ "observability_url": "https://trace.example.com/project/aropenclaw1/traces",
+ },
+ }
+
+ async def get_agent_logs(self, **kwargs):
+ _FakeOpenClawDetailClient.last_log_kwargs = kwargs
+ return {
+ "logs": ["line-1", "line-2"],
+ "total": 2,
+ "page": 1,
+ "page_size": 200,
+ "agent_id": "ar-demo-1",
+ "instance": kwargs.get("instance"),
+ "log_type": "Stdout",
+ }
+
+ async def close(self):
+ return None
+
+
+class _FakeOpenClawCreatingDetailClient(_FakeOpenClawDetailClient):
+ async def get_agent(self, **_kwargs):
+ payload = await super().get_agent(**_kwargs)
+ payload["basic"]["status"] = "CREATING"
+ return payload
+
+
+class _FakeOpenClawFailedDetailClient(_FakeOpenClawDetailClient):
+ last_repair_kwargs: Dict[str, Any] = {}
+
+ async def get_agent(self, **_kwargs):
+ payload = await super().get_agent(**_kwargs)
+ payload["basic"]["status"] = "FAILED"
+ return payload
+
+ async def run_openclaw_repair(self, agent_id: str, *, repair_action: str = "doctor-fix"):
+ self.__class__.last_repair_kwargs = {
+ "agent_id": agent_id,
+ "repair_action": repair_action,
+ }
+ return {
+ "ok": True,
+ "agent_id": agent_id,
+ "repair_action": repair_action,
+ "status": "succeeded",
+ "exit_code": 0,
+ "logs": "fixed",
+ }
+
+
+class _FakeGatewayClient:
+ applied_configs: list[Dict[str, Any]] = []
+ last_wait_kwargs: Dict[str, Any] = {}
+ disconnect_waits: list[int] = []
+
+ def __init__(self, *args, **kwargs):
+ self.methods = ["channels.status", "config.get", "web.login.start", "web.login.wait"]
+
+ async def build_access_info(self):
+ return SimpleNamespace(
+ access_url="https://dashboard.example.com/s/lnk-demo",
+ ws_url="wss://dashboard.example.com/",
+ link_id="lnk-demo",
+ expires_at="2026-03-23T12:00:00Z",
+ )
+
+ async def connect(self):
+ return {"features": {"methods": self.methods}}
+
+ async def close(self):
+ return None
+
+ async def wait_for_disconnect(self, *, timeout_ms=5_000):
+ self.__class__.disconnect_waits.append(timeout_ms)
+ return True
+
+ async def channels_status(self, *, probe=False, timeout_ms=None):
+ return {
+ "channels": {
+ "weixin": {"connected": True, "probe": probe, "timeout_ms": timeout_ms},
+ "feishu": {"enabled": True},
+ "wps-xiezuo": {"enabled": True},
+ }
+ }
+
+ async def config_get(self):
+ return {
+ "hash": "cfg-1",
+ "exists": True,
+ "config": {
+ "plugins": {"entries": {}},
+ "channels": {},
+ },
+ }
+
+ async def web_login_start(self, *, force=False, timeout_ms=None):
+ return {
+ "qrDataUrl": "https://qr.example.com/weixin-login",
+ "sessionKey": "sess-1",
+ "message": "scan now",
+ }
+
+ async def web_login_wait(self, *, account_id=None, session_key=None, timeout_ms=None):
+ self.__class__.last_wait_kwargs = {
+ "account_id": account_id,
+ "session_key": session_key,
+ "timeout_ms": timeout_ms,
+ }
+ return {"connected": True, "message": "connected"}
+
+ async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None):
+ self.__class__.applied_configs.append(
+ {
+ "config": config,
+ "base_hash": base_hash,
+ "note": note,
+ "session_key": session_key,
+ "restart_delay_ms": restart_delay_ms,
+ }
+ )
+ return {"ok": True}
+
+
+class _FakeConfigApplyReloadGatewayClient(_FakeGatewayClient):
+ async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None):
+ await super().config_apply(
+ config=config,
+ base_hash=base_hash,
+ note=note,
+ session_key=session_key,
+ restart_delay_ms=restart_delay_ms,
+ )
+ raise cmd_openclaw.OpenClawGatewayError(
+ "Gateway websocket receive failed: received 1011 (internal error) Bad Gateway"
+ )
+
+
+class _FakeDoctorGatewayClient(_FakeGatewayClient):
+ async def config_get(self):
+ return {
+ "hash": "cfg-1",
+ "exists": True,
+ "config": {
+ "plugins": {
+ "entries": {
+ "openclaw-weixin": {"enabled": True},
+ "openclaw-lark": {"enabled": True},
+ "wps-xiezuo": {"enabled": True},
+ }
+ },
+ "skills": {"allowBundled": ["wps365-skill"]},
+ "channels": {
+ "feishu": {"enabled": True},
+ "openclaw-weixin": {"accounts": {"default": {"enabled": True}}},
+ "wps-xiezuo": {"enabled": True, "appId": "app-demo", "appSecret": "secret-demo"},
+ },
+ },
+ }
+
+
+class _FakeDoctorFreshGatewayClient(_FakeGatewayClient):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.methods = ["channels.status", "config.get"]
+
+ async def channels_status(self, *, probe=False, timeout_ms=None):
+ return {
+ "channels": {
+ "openclaw-weixin": {"configured": False, "probe": probe, "timeout_ms": timeout_ms},
+ }
+ }
+
+ async def config_get(self):
+ return {
+ "hash": "cfg-1",
+ "exists": True,
+ "config": {
+ "plugins": {
+ "entries": {
+ "openclaw-weixin": {"enabled": True},
+ "openclaw-lark": {"enabled": True},
+ "wps-xiezuo": {"enabled": True},
+ }
+ },
+ "skills": {"allowBundled": ["wps365-skill"]},
+ "channels": {},
+ },
+ }
+
+
+class _FakeDoctorBrokenWeixinGatewayClient(_FakeDoctorFreshGatewayClient):
+ async def channels_status(self, *, probe=False, timeout_ms=None):
+ return {
+ "channels": {
+ "openclaw-weixin": {"configured": True, "probe": probe, "timeout_ms": timeout_ms},
+ }
+ }
+
+ async def config_get(self):
+ snapshot = await super().config_get()
+ snapshot["config"]["channels"] = {
+ "openclaw-weixin": {"accounts": {"default": {"enabled": True}}},
+ }
+ return snapshot
+
+
+class _FakeWeixinGatewayWithoutWebLoginClient(_FakeGatewayClient):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.methods = ["channels.status", "config.get"]
+
+ async def web_login_start(self, *, force=False, timeout_ms=None):
+ raise AssertionError("web login RPC should not be called when method discovery is missing")
+
+ async def channels_status(self, *, probe=False, timeout_ms=None):
+ return {
+ "channels": {
+ "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms},
+ }
+ }
+
+
+class _FakeWeixinGatewayProviderUnavailableClient(_FakeGatewayClient):
+ async def web_login_start(self, *, force=False, timeout_ms=None):
+ raise cmd_openclaw.OpenClawGatewayRequestError(
+ "web login provider is not available",
+ code="INVALID_REQUEST",
+ )
+
+ async def channels_status(self, *, probe=False, timeout_ms=None):
+ return {
+ "channels": {
+ "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms},
+ }
+ }
+
+
+class _FakeRestartingWeixinGatewayClient(_FakeGatewayClient):
+ async def web_login_start(self, *, force=False, timeout_ms=None):
+ if not self.__class__.disconnect_waits:
+ raise AssertionError("expected gateway restart wait before weixin login")
+ return await super().web_login_start(force=force, timeout_ms=timeout_ms)
+
+
+class _FakeDeleteProvider:
+ def __init__(self):
+ self.calls = []
+
+ async def destroy(self, agent_id, deploy_target):
+ self.calls.append((agent_id, deploy_target))
+ return True
+
+
+class _FakeBatchDeleteClient:
+ deleted_agents = []
+ deleted_mcps = []
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def delete_agent(self, agent_id):
+ self.deleted_agents.append(agent_id)
+ return True
+
+ async def delete_mcp(self, mcp_id):
+ self.deleted_mcps.append(mcp_id)
+ return True
+
+ async def close(self):
+ return None
+
+
+class _FakeOpenClawCreateClient:
+ get_agent_calls = 0
+ create_payload = None
+ update_payload = None
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def create_agent(self, _data):
+ self.__class__.create_payload = _data
+ return {
+ "agent_id": "ar-created-1",
+ "endpoint": "https://ar-created-1.agent.kspmas.ksyun.com",
+ "api_key": "ak-created-1",
+ }
+
+ async def update_agent(self, _agent_id, _data):
+ self.__class__.update_payload = _data
+ return {
+ "agent_id": _agent_id,
+ "endpoint": "https://ar-existing-1.agent.kspmas.ksyun.com",
+ "api_key": "ak-existing-1",
+ }
+
+ async def get_agent(self, **_kwargs):
+ self.__class__.get_agent_calls += 1
+ raise AssertionError("OpenClaw create response already contains complete quick access")
+
+ async def close(self):
+ return None
+
+
+class _FakeOpenClawImmediateAgentIdClient:
+ get_agent_calls = 0
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def create_agent(self, _data):
+ return {
+ "agent_id": "ar-created-2",
+ "endpoint": None,
+ "api_key": None,
+ "order_id": "ord-created-2",
+ }
+
+ async def get_agent(self, **kwargs):
+ self.__class__.get_agent_calls += 1
+ assert kwargs["agent_id"] == "ar-created-2"
+ assert kwargs["include_api_key"] is True
+ return {
+ "basic": {
+ "agent_id": "ar-created-2",
+ "name": "demo-openclaw",
+ "status": "RUNNING",
+ "framework": "openclaw",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://fresh-openclaw.example.com",
+ "api_key": "ak-fresh-openclaw",
+ },
+ }
+
+ async def close(self):
+ return None
+
+
+class _FakeOpenClawDelayedAccessClient:
+ get_agent_calls = 0
+ suppression_used = False
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ @contextmanager
+ def suppress_http_error_logging(self, predicate=None):
+ self.__class__.suppression_used = predicate is not None
+ yield
+
+ async def create_agent(self, _data):
+ return {
+ "agent_id": "ar-created-delayed",
+ "endpoint": "https://created-openclaw.example.com",
+ "api_key": None,
+ "order_id": "ord-created-delayed",
+ }
+
+ async def get_agent(self, **kwargs):
+ self.__class__.get_agent_calls += 1
+ if self.__class__.get_agent_calls < 4:
+ raise AgentEngineAPIError(
+ 404,
+ "未找到对应的 Agent",
+ details={
+ "http_status": 404,
+ "remote_error_message": "未找到对应的 Agent",
+ },
+ )
+ assert kwargs["agent_id"] == "ar-created-delayed"
+ assert kwargs["include_api_key"] is True
+ return {
+ "basic": {
+ "agent_id": "ar-created-delayed",
+ "name": "demo-openclaw",
+ "status": "RUNNING",
+ "framework": "openclaw",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://ready-openclaw.example.com",
+ "api_key": "ak-ready-openclaw",
+ },
+ "deployment": {
+ "framework": "openclaw",
+ "region": "cn-beijing-6",
+ },
+ }
+
+ async def close(self):
+ return None
+
+
+class _FakeDeleteClient:
+ deleted_agents = []
+ should_succeed = True
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def delete_agent(self, agent_id):
+ self.deleted_agents.append(agent_id)
+ if self.should_succeed:
+ return True
+ raise RuntimeError("delete failed")
+
+ async def close(self):
+ return None
+
+
+class _FakePartialDeleteProvider:
+ def __init__(self, results: Dict[str, bool]):
+ self.results = dict(results)
+ self.calls = []
+
+ async def destroy(self, agent_id, deploy_target):
+ self.calls.append((agent_id, deploy_target))
+ return self.results.get(agent_id, False)
+
+
+def test_run_async_with_dry_run_handles_exit(capsys):
+ async def _boom():
+ raise DryRunExit("done")
+
+ result = run_async_with_dry_run(_boom(), dry_run=True)
+ assert result is None
+ out = capsys.readouterr().out
+ assert "Dry Run Completed" in out
+
+
+def test_client_respects_global_dry_run_env(monkeypatch):
+ monkeypatch.setenv("AGENTENGINE_GLOBAL_DRY_RUN", "1")
+ client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="", dry_run=False)
+ assert client.dry_run is True
+
+
+def test_mcp_status_supports_dry_run(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ result = runner.invoke(
+ mcp,
+ ["status", "mcp-123", "--dry-run"],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "Dry Run Completed" in result.output
+ assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
+
+
+def test_mcp_deploy_dry_run_json_plan(monkeypatch, tmp_path: Path):
+ runner = CliRunner()
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ def _should_not_build(*_args, **_kwargs):
+ raise AssertionError("Dry run should not build artifacts")
+
+ monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
+
+ result = runner.invoke(
+ mcp,
+ ["deploy", ".", "--dry-run", "--output", "json", "--ks3-bucket", "agentengine-test"],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["resource"] == "workflow"
+ assert payload["action"] == "deploy"
+ assert payload["kind"] == "dry_run"
+ assert payload["request"]["body"]["artifact_type"] == "Code"
+ assert payload["plan"]["artifact"]["reference"].startswith("ks3://agentengine-test/")
+
+
+def test_mcp_deploy_dry_run_includes_explicit_network(monkeypatch, tmp_path: Path):
+ runner = CliRunner()
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ def _should_not_build(*_args, **_kwargs):
+ raise AssertionError("Dry run should not build artifacts")
+
+ monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
+
+ result = runner.invoke(
+ mcp,
+ [
+ "deploy",
+ ".",
+ "--dry-run",
+ "--output",
+ "json",
+ "--ks3-bucket",
+ "agentengine-test",
+ "--disable-public-access",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ "--availability-zone",
+ "cn-beijing-6b",
+ ],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["request"]["body"]["network"] == {
+ "enable_public_access": False,
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ "availability_zone": "cn-beijing-6b",
+ }
+
+
+def test_openclaw_list_supports_dry_run(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ result = runner.invoke(openclaw, ["list", "--dry-run"])
+
+ assert result.exit_code == 0, result.output
+ assert "Dry Run Completed" in result.output
+ assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
+
+
+def test_openclaw_list_shows_account_region_summary(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawListClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+
+ result = runner.invoke(
+ openclaw,
+ ["list", "--region", "cn-beijing-6"],
+ env={"KSYUN_ACCOUNT_ID": "2000003485"},
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "OpenClaw 列表" in result.output
+ assert "账号: 2000003485" in result.output
+ assert "region: cn-beijing-6" in result.output
+ assert "总计: 145" in result.output
+
+
+def test_openclaw_help_exposes_channel_and_gateway_commands():
+ runner = CliRunner()
+
+ result = runner.invoke(openclaw, ["--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "channel" in result.output
+ assert "gateway" in result.output
+ assert "tui" in result.output
+
+
+def test_openclaw_tui_help_states_no_local_openclaw_cli_required():
+ runner = CliRunner()
+
+ result = runner.invoke(openclaw, ["tui", "--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "不需要本机安装 OpenClaw CLI" in result.output
+
+
+def test_openclaw_tui_dry_run_does_not_resolve_or_connect(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_resolve(*_args, **_kwargs):
+ raise AssertionError("agent detail should not be resolved")
+
+ async def _forbidden_terminal(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _forbidden_resolve)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "tui",
+ "ar-demo-1",
+ "--gateway-token",
+ "gw-token",
+ "--message",
+ "你好",
+ "--thinking",
+ "medium",
+ "--history-limit",
+ "50",
+ "--timeout-ms",
+ "30000",
+ "--deliver",
+ "--dry-run",
+ "--output",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "dry_run"
+ assert payload["resource"] == "openclaw"
+ assert payload["action"] == "tui"
+ assert payload["request"]["agent_ref"] == "ar-demo-1"
+ assert payload["request"]["mode"] == "tui"
+ assert payload["request"]["gateway_token_provided"] is True
+ assert payload["request"]["options"] == {
+ "message": "你好",
+ "thinking": "medium",
+ "history_limit": 50,
+ "timeout_ms": 30000,
+ "deliver": True,
+ }
+ assert "gw-token" not in result.output
+
+
+def test_openclaw_tui_uses_gateway_token_for_native_terminal(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_resolve(agent_ref, *, region):
+ assert agent_ref == "ar-demo-1"
+ assert region == "pre-online"
+ return "pre-online", {
+ "agent_id": "ar-demo-1",
+ "name": "demo-openclaw",
+ "status": "RUNNING",
+ "framework": "openclaw",
+ "endpoint": "https://openclaw.example.com",
+ "api_key": "ak-agentengine",
+ "openclaw_auth_mode": "token",
+ }
+
+ async def _fake_terminal(**kwargs):
+ captured.update(kwargs)
+ return 0
+
+ monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "tui",
+ "ar-demo-1",
+ "--region",
+ "pre-online",
+ "--gateway-token",
+ "gw-token",
+ "--session",
+ "sess-1",
+ "--message",
+ "你好",
+ "--thinking",
+ "medium",
+ "--history-limit",
+ "50",
+ "--timeout-ms",
+ "30000",
+ "--deliver",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["endpoint"] == "https://openclaw.example.com"
+ assert captured["api_key"] == "gw-token"
+ assert captured["session_id"] == "sess-1"
+ assert captured["mode"] == "tui"
+ assert captured["argv"] == []
+ assert captured["options"] == {
+ "message": "你好",
+ "thinking": "medium",
+ "history_limit": 50,
+ "timeout_ms": 30000,
+ "deliver": True,
+ }
+
+
+def test_openclaw_tui_uses_state_gateway_token_for_native_terminal(monkeypatch, tmp_path: Path):
+ (tmp_path / ".agentengine.state").write_text(
+ yaml.safe_dump(
+ {
+ "agent_id": "ar-demo-1",
+ "type": "openclaw",
+ "framework": "openclaw",
+ "endpoint": "https://openclaw.example.com",
+ "api_key": "ak-agentengine",
+ "openclaw_auth_mode": "token",
+ "openclaw_gateway_token": "gw-token-from-state",
+ }
+ ),
+ encoding="utf-8",
+ )
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_terminal(**kwargs):
+ captured.update(kwargs)
+ return 0
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
+ monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
+
+ result = runner.invoke(openclaw, ["tui"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["endpoint"] == "https://openclaw.example.com"
+ assert captured["api_key"] == "gw-token-from-state"
+ assert "gw-token-from-state" not in result.output
+
+
+def test_openclaw_tui_requires_gateway_token_for_token_auth(monkeypatch):
+ runner = CliRunner()
+
+ async def _fake_resolve(_agent_ref, *, region):
+ return region or "pre-online", {
+ "agent_id": "ar-demo-1",
+ "status": "RUNNING",
+ "framework": "openclaw",
+ "endpoint": "https://openclaw.example.com",
+ "api_key": "ak-agentengine",
+ "openclaw_auth_mode": "token",
+ }
+
+ async def _forbidden_terminal(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
+ monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
+ monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False)
+
+ result = runner.invoke(openclaw, ["tui", "ar-demo-1"])
+
+ assert result.exit_code != 0
+ assert "OPENCLAW_GATEWAY_TOKEN" in result.output
+
+
+def test_openclaw_channel_connect_help_separates_channel_specific_options():
+ runner = CliRunner()
+
+ result = runner.invoke(openclaw, ["channel", "connect", "--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "微信:扫码登录" in result.output
+ assert "飞书:启动官方 onboarding 流程" in result.output
+ assert "WPS 协作:写入开放平台 appId/appSecret" in result.output
+ assert "仅 WPS 协作:开放平台应用 ID" in result.output
+ assert "仅微信:在本地浏览器额外打开二维码链接" in result.output
+ assert "--dm-policy=open 表示允许所有用户私聊" in result.output
+
+
+def test_openclaw_gateway_ws_url_prints_dashboard_and_ws(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert "dashboard.example.com/s/lnk-demo" in result.output
+ assert "wss://" in result.output
+ assert "cookie-session" in result.output
+
+
+def test_openclaw_gateway_logs_reads_agent_logs(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+
+ result = runner.invoke(
+ openclaw,
+ ["gateway", "logs", "ar-demo-1", "--instance", "oc-0", "--log-type", "stdout"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "line-1" in result.output
+ assert _FakeOpenClawDetailClient.last_log_kwargs["instance"] == "oc-0"
+ assert _FakeOpenClawDetailClient.last_log_kwargs["log_type"] == "stdout"
+
+
+def test_openclaw_gateway_doctor_checks_short_link_and_ws(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert "dashboard_short_link" in result.output
+ assert "cookie_ws_handshake" in result.output
+ assert "gateway_rpc" in result.output
+
+
+def test_openclaw_gateway_ws_url_allows_creating_when_gateway_is_reachable(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert "dashboard.example.com/s/lnk-demo" in result.output
+ assert "wss://" in result.output
+
+
+def test_openclaw_gateway_doctor_continues_probe_when_status_is_creating(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert '"status": "CREATING"' in result.output
+ assert '"dashboard_short_link"' in result.output
+ assert '"ok": true' in result.output.lower()
+
+
+def test_openclaw_gateway_doctor_fix_uses_control_plane_repair_for_failed_runtime(monkeypatch):
+ runner = CliRunner()
+ _FakeOpenClawFailedDetailClient.last_repair_kwargs = {}
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient)
+
+ result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1", "--fix"])
+
+ assert result.exit_code == 0, result.output
+ assert '"repair_action": "doctor-fix"' in result.output
+ assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == {
+ "agent_id": "ar-demo-1",
+ "repair_action": "doctor-fix",
+ }
+
+
+def test_openclaw_repair_command_runs_doctor_fix_via_control_plane(monkeypatch):
+ runner = CliRunner()
+ _FakeOpenClawFailedDetailClient.last_repair_kwargs = {}
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient)
+
+ result = runner.invoke(openclaw, ["repair", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert '"repair_action": "doctor-fix"' in result.output
+ assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == {
+ "agent_id": "ar-demo-1",
+ "repair_action": "doctor-fix",
+ }
+
+
+def test_openclaw_channel_status_uses_gateway_snapshot(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin", "--probe"])
+
+ assert result.exit_code == 0, result.output
+ assert '"connected": true' in result.output.lower()
+ assert '"probe": true' in result.output.lower()
+
+
+def test_openclaw_channel_status_allows_creating_when_gateway_is_reachable(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+
+ result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert '"connected": true' in result.output.lower()
+
+
+def test_openclaw_channel_enable_updates_weixin_account_config(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(
+ openclaw,
+ ["channel", "enable", "ar-demo-1", "--channel", "weixin", "--account-id", "wx-demo"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeGatewayClient.applied_configs
+ config = _FakeGatewayClient.applied_configs[-1]["config"]
+ assert config["channels"]["openclaw-weixin"]["accounts"]["wx-demo"]["enabled"] is True
+
+
+def test_openclaw_channel_connect_weixin_prints_qr_url(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+ _FakeGatewayClient.last_wait_kwargs = {}
+ _FakeGatewayClient.disconnect_waits = []
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert "https://qr.example.com/weixin-login" in result.output
+ assert _FakeGatewayClient.applied_configs
+ config = _FakeGatewayClient.applied_configs[-1]["config"]
+ assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True
+ assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True
+ assert _FakeGatewayClient.last_wait_kwargs["account_id"] == "sess-1"
+
+
+def test_openclaw_channel_connect_weixin_waits_for_gateway_restart(monkeypatch):
+ runner = CliRunner()
+ _FakeRestartingWeixinGatewayClient.applied_configs = []
+ _FakeRestartingWeixinGatewayClient.last_wait_kwargs = {}
+ _FakeRestartingWeixinGatewayClient.disconnect_waits = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeRestartingWeixinGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeRestartingWeixinGatewayClient.disconnect_waits == [5_000]
+ assert _FakeRestartingWeixinGatewayClient.last_wait_kwargs["account_id"] == "sess-1"
+
+
+def test_openclaw_channel_connect_weixin_maps_session_key_to_account_id(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.last_wait_kwargs = {}
+ _FakeGatewayClient.disconnect_waits = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeGatewayClient.last_wait_kwargs == {
+ "account_id": "sess-1",
+ "session_key": None,
+ "timeout_ms": 120_000,
+ }
+
+
+def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_without_web_login_rpc(monkeypatch):
+ runner = CliRunner()
+ _FakeWeixinGatewayWithoutWebLoginClient.applied_configs = []
+ _FakeWeixinGatewayWithoutWebLoginClient.disconnect_waits = []
+ captured: Dict[str, Any] = {}
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ async def _fake_terminal(**kwargs):
+ captured.update(kwargs)
+ return 0
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayWithoutWebLoginClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["endpoint"] == "https://openclaw.example.com"
+ assert captured["api_key"] is None
+ assert captured["mode"] == "exec"
+ assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"]
+ assert _FakeWeixinGatewayWithoutWebLoginClient.applied_configs
+ config = _FakeWeixinGatewayWithoutWebLoginClient.applied_configs[-1]["config"]
+ assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True
+ assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True
+ assert '"mode": "remote_cli"' in result.output
+
+
+def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_when_provider_unavailable(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ async def _fake_terminal(**kwargs):
+ captured.update(kwargs)
+ return 0
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayProviderUnavailableClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+ monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["mode"] == "exec"
+ assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"]
+ assert "web login provider is not available" in result.output
+
+
+def test_openclaw_channel_connect_feishu_applies_remote_config(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ async def _fake_onboarding(existing_app_id):
+ assert existing_app_id is None
+ return {
+ "appId": "cli-app-id",
+ "appSecret": "cli-app-secret",
+ "domain": "lark",
+ "userInfo": {"openId": "ou_demo"},
+ }
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._run_feishu_onboarding", _fake_onboarding)
+
+ result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "feishu"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeGatewayClient.applied_configs
+ config = _FakeGatewayClient.applied_configs[-1]["config"]
+ assert config["plugins"]["entries"]["openclaw-lark"]["enabled"] is True
+ assert config["channels"]["feishu"]["enabled"] is True
+ assert config["channels"]["feishu"]["appId"] == "cli-app-id"
+ assert config["channels"]["feishu"]["appSecret"] == "cli-app-secret"
+ assert config["channels"]["feishu"]["domain"] == "lark"
+ assert config["channels"]["feishu"]["allowFrom"] == ["ou_demo"]
+ assert config["channels"]["feishu"]["groupAllowFrom"] == ["ou_demo"]
+
+
+def test_should_auto_open_browser_on_local_macos(monkeypatch):
+ from ksadk.cli.cmd_openclaw import _should_auto_open_browser
+
+ monkeypatch.delenv("SSH_TTY", raising=False)
+ monkeypatch.delenv("SSH_CONNECTION", raising=False)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin")
+
+ assert _should_auto_open_browser() is True
+
+
+def test_should_not_auto_open_browser_over_ssh(monkeypatch):
+ from ksadk.cli.cmd_openclaw import _should_auto_open_browser
+
+ monkeypatch.setenv("SSH_TTY", "/dev/pts/1")
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin")
+
+ assert _should_auto_open_browser() is False
+
+
+def test_openclaw_channel_connect_wps_xiezuo_applies_flat_remote_config(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "channel",
+ "connect",
+ "ar-demo-1",
+ "--channel",
+ "wps-xiezuo",
+ "--app-id",
+ "app-demo",
+ "--app-secret",
+ "secret-demo",
+ "--dm-policy",
+ "open",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeGatewayClient.applied_configs
+ config = _FakeGatewayClient.applied_configs[-1]["config"]
+ assert config["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True
+ assert "wps-xiezuo" in config["plugins"]["allow"]
+ channel = config["channels"]["wps-xiezuo"]
+ assert channel["enabled"] is True
+ assert channel["appId"] == "app-demo"
+ assert channel["appSecret"] == "secret-demo"
+ assert channel["baseUrl"] == "https://openapi.wps.cn"
+ assert channel["dmPolicy"] == "open"
+ assert channel["allowFrom"] == ["*"]
+ assert channel["groupPolicy"] == "open"
+ assert channel["sdk"] == {"enabled": True, "logLevel": "info"}
+ assert channel["instantAck"]["text"] == "内容处理中,请稍候..."
+ assert channel["mcp"]["enabled"] is True
+ assert channel["mcp"]["mode"] == "app"
+ assert "toolAllowlist" not in channel["mcp"]
+ assert "accounts" not in channel
+ assert "defaultAccountId" not in channel
+ assert config["bindings"] == [
+ {"type": "route", "agentId": "main", "match": {"channel": "wps-xiezuo"}}
+ ]
+
+
+def test_openclaw_channel_connect_wps_xiezuo_tolerates_reload_disconnect(monkeypatch):
+ runner = CliRunner()
+ _FakeConfigApplyReloadGatewayClient.applied_configs = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeConfigApplyReloadGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "channel",
+ "connect",
+ "ar-demo-1",
+ "--channel",
+ "wps-xiezuo",
+ "--app-id",
+ "app-demo",
+ "--app-secret",
+ "secret-demo",
+ "--dm-policy",
+ "open",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "gateway reload 期间连接短暂中断" in result.output
+ assert _FakeConfigApplyReloadGatewayClient.applied_configs
+ config = _FakeConfigApplyReloadGatewayClient.applied_configs[-1]["config"]
+ assert config["channels"]["wps-xiezuo"]["appId"] == "app-demo"
+
+
+def test_openclaw_channel_connect_wps_xiezuo_rejects_non_default_account(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "channel",
+ "connect",
+ "ar-demo-1",
+ "--channel",
+ "wps-xiezuo",
+ "--app-id",
+ "app-demo",
+ "--app-secret",
+ "secret-demo",
+ "--account-id",
+ "tenant-a",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "仅支持 default" in result.output
+
+
+def test_openclaw_channel_connect_wps_xiezuo_requires_app_secret_when_dm_disabled(monkeypatch):
+ runner = CliRunner()
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "channel",
+ "connect",
+ "ar-demo-1",
+ "--channel",
+ "wps-xiezuo",
+ "--app-id",
+ "app-demo",
+ "--dm-policy",
+ "disabled",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "必须提供 --app-secret" in result.output
+
+
+def test_openclaw_channel_disable_wps_xiezuo_updates_flat_channel(monkeypatch):
+ runner = CliRunner()
+ _FakeGatewayClient.applied_configs = []
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
+
+ result = runner.invoke(openclaw, ["channel", "disable", "ar-demo-1", "--channel", "wps-xiezuo"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeGatewayClient.applied_configs
+ config = _FakeGatewayClient.applied_configs[-1]["config"]
+ assert config["channels"]["wps-xiezuo"]["enabled"] is False
+ assert "accounts" not in config["channels"]["wps-xiezuo"]
+
+
+def test_openclaw_channel_doctor_checks_snapshot_and_local_node(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.shutil.which",
+ lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
+ )
+
+ result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "feishu"])
+
+ assert result.exit_code == 0, result.output
+ assert "feishu_plugin_visible" in result.output
+ assert "feishu_status_snapshot" in result.output
+ assert "feishu_local_node" in result.output
+
+
+def test_openclaw_channel_doctor_checks_wps_xiezuo_plugin_and_deps(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
+ lambda: {
+ "ok": True,
+ "node": "/usr/bin/node",
+ "npm": "/usr/bin/npm",
+ },
+ )
+
+ result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "wps-xiezuo"])
+
+ assert result.exit_code == 0, result.output
+ assert "wps_xiezuo_plugin_visible" in result.output
+ assert "wps_xiezuo_status_snapshot" in result.output
+ assert "wps_xiezuo_local_deps" in result.output
+
+
+def test_openclaw_channel_doctor_treats_unconfigured_channels_as_connect_required(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorFreshGatewayClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.shutil.which",
+ lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
+ )
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
+ lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"},
+ )
+
+ result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--output", "json"])
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ checks = {item["name"]: item for item in payload["checks"]}
+ assert payload["ok"] is False
+ assert checks["weixin_qr_rpc"]["ok"] is False
+ assert checks["weixin_qr_rpc"]["state"] == "connect_required"
+ assert checks["feishu_status_snapshot"]["state"] == "connect_required"
+ assert checks["wps_xiezuo_status_snapshot"]["state"] == "connect_required"
+
+
+def test_openclaw_channel_doctor_keeps_configured_weixin_qr_rpc_as_hard_failure(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorBrokenWeixinGatewayClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.shutil.which",
+ lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
+ )
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
+ lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"},
+ )
+
+ result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "weixin", "--output", "json"])
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ checks = {item["name"]: item for item in payload["checks"]}
+ assert payload["ok"] is False
+ assert checks["weixin_qr_rpc"]["state"] == "missing"
+
+
+def test_openclaw_deploy_supports_security_profile_flags(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_deploy_openclaw(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(openclaw, ["deploy", "--strictest"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["security_profile"] == "strictest"
+
+
+def test_openclaw_deploy_forwards_custom_env_pairs(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_deploy_openclaw(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--env",
+ "FOO=bar",
+ "--env",
+ "OPENCLAW_GATEWAY_PORT=9090",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["extra_env"] == ("FOO=bar", "OPENCLAW_GATEWAY_PORT=9090")
+
+
+def test_openclaw_deploy_forwards_explicit_memory_config(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_deploy_openclaw(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--memory-system",
+ "mem0",
+ "--mem0-instance-id",
+ "c17b20b1-faf7-4c98-91a7-38d1ee581ba1",
+ "--mem0-instance-name",
+ "mem-demo",
+ "--mem0-region",
+ "pre-online",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["memory_system"] == "mem0"
+ assert captured["mem0_instance_id"] == "c17b20b1-faf7-4c98-91a7-38d1ee581ba1"
+ assert captured["mem0_instance_name"] == "mem-demo"
+ assert captured["mem0_region"] == "pre-online"
+
+
+def test_openclaw_deploy_forwards_network_cli_options(monkeypatch):
+ runner = CliRunner()
+ captured: Dict[str, Any] = {}
+
+ async def _fake_deploy_openclaw(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--disable-public-access",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ "--availability-zone",
+ "cn-beijing-6b",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["enable_public_access"] is False
+ assert captured["enable_vpc_access"] is True
+ assert captured["vpc_id"] == "vpc-cli"
+ assert captured["subnet_id"] == "subnet-cli"
+ assert captured["security_group_id"] == "sg-cli"
+ assert captured["availability_zone"] == "cn-beijing-6b"
+
+
+def test_openclaw_deploy_rejects_mem0_without_instance_id():
+ runner = CliRunner()
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--memory-system",
+ "mem0",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "--mem0-instance-id" in result.output
+
+
+def test_openclaw_default_image_ref_tracks_current_runtime_tag():
+ from ksadk.cli.cmd_openclaw import _resolve_image_ref
+
+ assert _resolve_image_ref(None) == "ghcr.io/kingsoftcloud/agentengine-public/openclaw:2026.6.1"
+
+
+def test_openclaw_deploy_create_payload_includes_network(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ "--disable-public-access",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ "--availability-zone",
+ "cn-beijing-6b",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.create_payload["network"] == {
+ "enable_public_access": False,
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ "availability_zone": "cn-beijing-6b",
+ }
+
+
+def test_openclaw_deploy_uses_init_project_name_when_name_is_omitted(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "agentengine.yaml").write_text(
+ yaml.safe_dump(
+ {
+ "name": "custom-openclaw",
+ "framework": "openclaw",
+ "entry_point": "custom_openclaw/agent.py",
+ }
+ ),
+ encoding="utf-8",
+ )
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.create_payload["name"] == "custom-openclaw"
+
+
+def test_openclaw_deploy_update_payload_includes_network(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ yaml.safe_dump(
+ {
+ "type": "openclaw",
+ "agent_id": "ar-existing-1",
+ "name": "demo-openclaw",
+ "endpoint": "https://existing.example.com",
+ "api_key": "ak-existing",
+ }
+ ),
+ encoding="utf-8",
+ )
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.create_payload is None
+ assert _FakeOpenClawCreateClient.update_payload["network"] == {
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ }
+
+
+def test_openclaw_deploy_update_payload_preserves_existing_config_by_default(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ yaml.safe_dump(
+ {
+ "type": "openclaw",
+ "agent_id": "ar-existing-1",
+ "name": "demo-openclaw",
+ "endpoint": "https://existing.example.com",
+ "api_key": "ak-existing",
+ }
+ ),
+ encoding="utf-8",
+ )
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.create_payload is None
+ assert _FakeOpenClawCreateClient.update_payload["artifact_path"] == (
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new"
+ )
+ assert "env_vars" not in _FakeOpenClawCreateClient.update_payload
+ assert "storage" not in _FakeOpenClawCreateClient.update_payload
+ assert "network" not in _FakeOpenClawCreateClient.update_payload
+ assert "memory_config" not in _FakeOpenClawCreateClient.update_payload
+
+
+def test_openclaw_deploy_update_payload_includes_explicit_config(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ yaml.safe_dump(
+ {
+ "type": "openclaw",
+ "agent_id": "ar-existing-1",
+ "name": "demo-openclaw",
+ "endpoint": "https://existing.example.com",
+ "api_key": "ak-existing",
+ }
+ ),
+ encoding="utf-8",
+ )
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new",
+ "--model-base-url",
+ "https://model.example.com/v1",
+ "--default-model",
+ "glm-test",
+ "--env",
+ "APP_MODE=prod",
+ "--storage-size-gi",
+ "50",
+ "--memory-system",
+ "openclaw_default",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = _FakeOpenClawCreateClient.update_payload
+ assert any(item["Key"] == "APP_MODE" and item["Value"] == "prod" for item in payload["env_vars"])
+ assert payload["storage"]["size_gi"] == 50
+ assert payload["memory_config"] == {"memory_system": "openclaw_default"}
+
+
+def test_openclaw_deploy_network_ids_imply_vpc_access(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.update_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.create_payload["network"]["enable_vpc_access"] is True
+
+
+def test_openclaw_deploy_rejects_incomplete_vpc_network():
+ runner = CliRunner()
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "VpcId、SubnetId、SecurityGroupId" in result.output
+
+
+def test_openclaw_deploy_does_not_query_get_agent_when_quick_access_is_already_complete(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawCreateClient.get_agent_calls == 0
+
+
+def test_openclaw_deploy_persists_gateway_token_from_extra_env(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ "--env",
+ "OPENCLAW_GATEWAY_AUTH_MODE=token",
+ "--env",
+ "OPENCLAW_GATEWAY_TOKEN=gw-token-from-deploy",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "gw-token-from-deploy" not in result.output
+ state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
+ assert state["openclaw_auth_mode"] == "token"
+ assert state["openclaw_gateway_token"] == "gw-token-from-deploy"
+
+
+def test_openclaw_deploy_writes_only_configured_model_from_provider_catalog(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ async def _fake_fetch_provider_model_catalog(**_kwargs):
+ return [
+ {
+ "id": "glm-5.1",
+ "context_window_tokens": 128_000,
+ "max_output_tokens": 8_192,
+ },
+ {
+ "id": "deepseek-v4-pro",
+ "context_window_tokens": 1_000_000,
+ "max_output_tokens": 384_000,
+ },
+ {
+ "id": "kimi-k2.6",
+ "context_window_tokens": 256_000,
+ "max_output_tokens": 32_000,
+ },
+ ]
+
+ monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ env_vars = {
+ item["Key"]: item["Value"]
+ for item in _FakeOpenClawCreateClient.create_payload["env_vars"]
+ }
+ catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"])
+ assert [item["id"] for item in catalog] == ["deepseek-v4-pro"]
+ assert catalog[0]["contextWindow"] == 1_000_000
+ assert catalog[0]["maxTokens"] == 384_000
+
+
+def test_openclaw_deploy_writes_allowlisted_models_from_provider_catalog(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENCLAW_MODEL_ALLOWLIST", "deepseek-v4-pro,glm-5.1")
+ _FakeOpenClawCreateClient.create_payload = None
+ _FakeOpenClawCreateClient.get_agent_calls = 0
+
+ async def _fake_fetch_provider_model_catalog(**_kwargs):
+ return [
+ {
+ "id": "glm-5.1",
+ "context_window_tokens": 128_000,
+ "max_output_tokens": 8_192,
+ },
+ {
+ "id": "deepseek-v4-pro",
+ "context_window_tokens": 1_000_000,
+ "max_output_tokens": 384_000,
+ },
+ {
+ "id": "kimi-k2.6",
+ "context_window_tokens": 256_000,
+ "max_output_tokens": 32_000,
+ },
+ ]
+
+ monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog)
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ env_vars = {
+ item["Key"]: item["Value"]
+ for item in _FakeOpenClawCreateClient.create_payload["env_vars"]
+ }
+ catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"])
+ assert [item["id"] for item in catalog] == ["deepseek-v4-pro", "glm-5.1"]
+ assert "kimi-k2.6" not in {item["id"] for item in catalog}
+
+
+def test_openclaw_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawImmediateAgentIdClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawImmediateAgentIdClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawImmediateAgentIdClient.get_agent_calls == 1
+ state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
+ assert state["agent_id"] == "ar-created-2"
+ assert state["endpoint"] == "https://fresh-openclaw.example.com"
+ assert state["api_key"] == "ak-fresh-openclaw"
+
+
+def test_openclaw_deploy_retries_transient_get_agent_not_found_until_api_key_is_ready(monkeypatch, tmp_path):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDelayedAccessClient)
+ monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
+ monkeypatch.chdir(tmp_path)
+ _FakeOpenClawDelayedAccessClient.get_agent_calls = 0
+ _FakeOpenClawDelayedAccessClient.suppression_used = False
+
+ result = runner.invoke(
+ openclaw,
+ [
+ "deploy",
+ "--name",
+ "demo-openclaw",
+ "--image",
+ "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeOpenClawDelayedAccessClient.suppression_used is True
+ assert _FakeOpenClawDelayedAccessClient.get_agent_calls == 4
+ state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
+ assert state["agent_id"] == "ar-created-delayed"
+ assert state["endpoint"] == "https://ready-openclaw.example.com"
+ assert state["api_key"] == "ak-ready-openclaw"
+
+
+def test_openclaw_flatten_agent_detail_reads_framework_and_region_from_deployment():
+ detail = cmd_openclaw._flatten_agent_detail(
+ {
+ "basic": {
+ "agent_id": "ar-openclaw-demo",
+ "name": "demo-openclaw",
+ "status": "running",
+ },
+ "quick_access": {
+ "public_endpoint": "https://demo-openclaw.example.com",
+ "api_key": "ak-demo-openclaw",
+ },
+ "deployment": {
+ "framework": "openclaw",
+ "region": "pre-online",
+ "artifact_path": "hub/openclaw:test",
+ },
+ }
+ )
+
+ assert detail["framework"] == "openclaw"
+ assert detail["region"] == "pre-online"
+ assert detail["api_key"] == "ak-demo-openclaw"
+
+
+def test_version_list_supports_dry_run(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+ monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6")
+
+ result = runner.invoke(version, ["list", "--agent", "demo-agent", "--dry-run"])
+
+ assert result.exit_code == 0, result.output
+ assert "Dry Run Completed" in result.output
+ assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
+
+
+def test_top_level_delete_accepts_force_alias(monkeypatch):
+ runner = CliRunner()
+ provider = _FakeDeleteProvider()
+ monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
+
+ result = runner.invoke(
+ destroy_delete,
+ ["ar-123", "--account-id", "2000003485", "--force", "--dry-run"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert provider.calls
+ assert provider.calls[0][0] == "ar-123"
+
+
+def test_top_level_destroy_accepts_yes_alias(monkeypatch):
+ runner = CliRunner()
+ provider = _FakeDeleteProvider()
+ monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
+
+ result = runner.invoke(
+ destroy_cmd,
+ ["ar-456", "--account-id", "2000003485", "--yes", "--dry-run"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert provider.calls
+ assert provider.calls[0][0] == "ar-456"
+
+
+def test_openclaw_destroy_accepts_force_alias(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "--force", "--dry-run"])
+
+ assert result.exit_code == 0, result.output
+ assert "Dry Run Completed" in result.output
+ assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
+
+
+def test_mcp_destroy_accepts_force_alias(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ result = runner.invoke(
+ mcp,
+ ["destroy", "mcp-123", "--force", "--dry-run"],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "Dry Run Completed" in result.output
+ assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
+
+
+def test_root_cli_registers_delete_alias():
+ _register_commands()
+ assert "agent" in cli.commands
+ assert "delete" in cli.commands
+ assert "destroy" in cli.commands
+ assert cli.get_command(None, "delete").hidden is True
+ assert cli.get_command(None, "destroy").hidden is True
+
+
+def test_root_help_shows_canonical_commands_only():
+ runner = CliRunner()
+ _register_commands()
+
+ result = runner.invoke(cli, ["--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "agentengine agent" in result.output
+ assert "agentengine status" not in result.output
+ assert "agentengine invoke" not in result.output
+ assert "agentengine delete" not in result.output
+ assert "agentengine destroy" not in result.output
+
+
+def test_agent_group_exposes_canonical_subcommands():
+ runner = CliRunner()
+
+ result = runner.invoke(agent, ["--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "list" in result.output
+ assert "status" in result.output
+ assert "invoke" in result.output
+ assert "delete" in result.output
+
+
+def test_root_status_all_routes_with_compatibility_hint(monkeypatch):
+ runner = CliRunner()
+ _register_commands()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
+
+ result = runner.invoke(
+ cli,
+ ["status", "--all", "--account-id", "2000003485", "--dry-run"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "agentengine agent list" in result.output
+ assert "Dry Run Completed" in result.output
+
+
+def test_root_invoke_alias_still_callable_with_hint(monkeypatch):
+ runner = CliRunner()
+ _register_commands()
+ invoked = {}
+
+ def fake_invoke_tui(
+ endpoint,
+ api_key,
+ session_id,
+ insecure,
+ model,
+ show_thinking,
+ api_format=None,
+ responses_session_header=None,
+ ):
+ return invoked.setdefault("endpoint", endpoint)
+
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_invoke._invoke_tui",
+ fake_invoke_tui,
+ )
+
+ result = runner.invoke(cli, ["invoke", "--endpoint", "http://demo.local"])
+
+ assert result.exit_code == 0, result.output
+ assert "agentengine agent invoke" in result.output
+ assert invoked["endpoint"] == "http://demo.local"
+
+
+def test_legacy_root_help_points_to_canonical_commands():
+ runner = CliRunner()
+ _register_commands()
+
+ result = runner.invoke(cli, ["status", "--help"])
+
+ assert result.exit_code == 0, result.output
+ assert "这是兼容入口" in result.output
+ assert "agentengine agent status --help" in result.output
+
+
+def test_top_level_delete_supports_multiple_ids(monkeypatch):
+ runner = CliRunner()
+ provider = _FakeDeleteProvider()
+ monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
+
+ result = runner.invoke(
+ destroy_delete,
+ ["ar-123", "ar-456", "--account-id", "2000003485", "--force", "--dry-run"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"]
+
+
+def test_top_level_destroy_supports_repeated_agent_option(monkeypatch):
+ runner = CliRunner()
+ provider = _FakeDeleteProvider()
+ monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
+
+ result = runner.invoke(
+ destroy_cmd,
+ ["--agent", "ar-123", "--agent", "ar-456", "--account-id", "2000003485", "--yes", "--dry-run"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"]
+
+
+def test_openclaw_destroy_supports_multiple_ids(monkeypatch):
+ runner = CliRunner()
+ _FakeBatchDeleteClient.deleted_agents = []
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "ar-demo-2", "--force"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeBatchDeleteClient.deleted_agents == ["ar-demo-1", "ar-demo-2"]
+
+
+def test_openclaw_delete_passes_result_styles_to_descriptor(monkeypatch):
+ runner = CliRunner()
+ _FakeBatchDeleteClient.deleted_agents = []
+ captured = {}
+
+ def _fake_render_descriptor_status(*args, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_openclaw.render_descriptor_status",
+ _fake_render_descriptor_status,
+ )
+
+ result = runner.invoke(openclaw, ["delete", "ar-demo-1", "--yes"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["fields"][1] == ("已删除", "ar-demo-1", "ok")
+ assert captured["fields"][2] == ("失败", "-", "muted")
+ assert captured["next_steps"] == (
+ "agentengine openclaw list",
+ "agentengine openclaw deploy",
+ )
+
+
+def test_openclaw_status_shows_langfuse_trace_url(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
+
+ result = runner.invoke(openclaw, ["status", "ar-demo-1"])
+
+ assert result.exit_code == 0, result.output
+ assert "Langfuse" in result.output
+ assert "https://trace.example.com/project/aropenclaw1/traces" in result.output
+
+
+def test_mcp_destroy_supports_multiple_ids(monkeypatch):
+ runner = CliRunner()
+ _FakeBatchDeleteClient.deleted_mcps = []
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_mcp.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+
+ result = runner.invoke(
+ mcp,
+ ["destroy", "mcp-123", "mcp-456", "--force"],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeBatchDeleteClient.deleted_mcps == ["mcp-123", "mcp-456"]
+
+
+def test_mcp_delete_passes_result_styles_to_descriptor(monkeypatch):
+ runner = CliRunner()
+ _FakeBatchDeleteClient.deleted_mcps = []
+ captured = {}
+
+ def _fake_render_descriptor_status(*args, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_mcp.run_async_with_dry_run",
+ lambda coro, dry_run: asyncio.run(coro),
+ )
+ monkeypatch.setattr(
+ "ksadk.cli.cmd_mcp.render_descriptor_status",
+ _fake_render_descriptor_status,
+ )
+
+ result = runner.invoke(
+ mcp,
+ ["delete", "mcp-123", "--yes"],
+ env={"AGENTENGINE_SERVER_URL": "http://example.com"},
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["fields"][1] == ("已删除", "mcp-123", "ok")
+ assert captured["fields"][2] == ("失败", "-", "muted")
+ assert captured["next_steps"] == (
+ "agentengine mcp list",
+ "agentengine mcp deploy",
+ )
+
+
+def test_agent_delete_json_requires_yes(monkeypatch):
+ runner = CliRunner()
+ _register_commands()
+
+ async def _resolve(ids, _region, _account_id):
+ return ids
+
+ monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
+
+ result = runner.invoke(
+ cli,
+ ["--output", "json", "agent", "delete", "ar-123", "--account-id", "2000003485"],
+ )
+
+ assert result.exit_code == 2, result.output
+ payload = json.loads(result.output.strip())
+ assert payload["ok"] is False
+ assert payload["error"]["code"] == "usage_error"
+ assert "--yes" in payload["error"]["message"]
+
+
+def test_agent_delete_json_returns_error_on_partial_failure(monkeypatch):
+ runner = CliRunner()
+ _register_commands()
+ provider = _FakePartialDeleteProvider({"ar-1": True, "ar-2": False})
+
+ async def _resolve(ids, _region, _account_id):
+ return ids
+
+ monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
+ monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
+
+ result = runner.invoke(
+ cli,
+ ["--output", "json", "agent", "delete", "ar-1", "ar-2", "--account-id", "2000003485", "--yes"],
+ )
+
+ assert result.exit_code == 6, result.output
+ payload = json.loads(result.output.strip())
+ assert payload["ok"] is False
+ assert payload["error"]["code"] == "remote_error"
+ assert payload["error"]["details"]["deleted"] == ["ar-1"]
+ assert payload["error"]["details"]["failed"] == ["ar-2"]
+
+
+def test_agent_delete_cancel_returns_cancelled_exit_code(monkeypatch):
+ runner = CliRunner()
+ _register_commands()
+
+ async def _resolve(ids, _region, _account_id):
+ return ids
+
+ monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
+
+ result = runner.invoke(
+ cli,
+ ["agent", "delete", "ar-1", "--account-id", "2000003485"],
+ input="n\n",
+ )
+
+ assert result.exit_code == 7, result.output
+ assert "已取消" in result.output
+
+
+def test_serverless_destroy_cleans_local_state_only_after_success(tmp_path, monkeypatch):
+ provider = ServerlessProvider()
+ state_file = tmp_path / ".agentengine.state"
+ state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
+ _FakeDeleteClient.deleted_agents = []
+ _FakeDeleteClient.should_succeed = True
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
+
+ success = asyncio.run(
+ provider.destroy(
+ "ar-demo",
+ DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}),
+ )
+ )
+
+ assert success is True
+ assert _FakeDeleteClient.deleted_agents == ["ar-demo"]
+ assert state_file.exists() is False
+
+
+def test_serverless_destroy_keeps_local_state_on_dry_run(tmp_path, monkeypatch):
+ provider = ServerlessProvider()
+ state_file = tmp_path / ".agentengine.state"
+ state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ class _DryRunDeleteClient(_FakeDeleteClient):
+ async def delete_agent(self, agent_id):
+ raise DryRunExit(
+ "dry-run",
+ payload={"method": "POST", "url": "https://example.com", "curl": "curl -X POST https://example.com"},
+ )
+
+ monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _DryRunDeleteClient)
+
+ try:
+ asyncio.run(
+ provider.destroy(
+ "ar-demo",
+ DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": True}),
+ )
+ )
+ except DryRunExit:
+ pass
+ else:
+ raise AssertionError("DryRunExit should bubble for CLI handling")
+
+ assert state_file.exists() is True
+
+
+def test_serverless_destroy_keeps_local_state_when_remote_delete_fails(tmp_path, monkeypatch):
+ provider = ServerlessProvider()
+ state_file = tmp_path / ".agentengine.state"
+ state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
+ _FakeDeleteClient.deleted_agents = []
+ _FakeDeleteClient.should_succeed = False
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
+
+ success = asyncio.run(
+ provider.destroy(
+ "ar-demo",
+ DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}),
+ )
+ )
+
+ assert success is False
+ assert state_file.exists() is True
+
+
+def test_serverless_destroy_uses_explicit_project_dir_for_state_cleanup(tmp_path, monkeypatch):
+ provider = ServerlessProvider()
+ project_dir = tmp_path / "project"
+ other_dir = tmp_path / "other"
+ project_dir.mkdir()
+ other_dir.mkdir()
+
+ project_state = project_dir / ".agentengine.state"
+ project_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
+ other_state = other_dir / ".agentengine.state"
+ other_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
+
+ _FakeDeleteClient.deleted_agents = []
+ _FakeDeleteClient.should_succeed = True
+ monkeypatch.chdir(other_dir)
+ monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
+
+ success = asyncio.run(
+ provider.destroy(
+ "ar-demo",
+ DeployTarget(
+ provider="serverless",
+ region="cn-beijing-6",
+ extra={"dry_run": False, "project_dir": str(project_dir)},
+ ),
+ )
+ )
+
+ assert success is True
+ assert project_state.exists() is False
+ assert other_state.exists() is True
diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py
new file mode 100644
index 0000000..bf88e79
--- /dev/null
+++ b/tests/test_cmd_hermes.py
@@ -0,0 +1,1419 @@
+import asyncio
+import json
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+from click.testing import CliRunner
+
+from ksadk.api.client import AgentEngineAPIError, DryRunExit
+from ksadk.cli import cmd_hermes
+from ksadk.cli.ui import OUTPUT_MODE_PRETTY, configure_ui_runtime, status_rich_style
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+HERMES_DOCKERFILE = REPO_ROOT / "deploy" / "hermes" / "Dockerfile"
+MAKEFILE = REPO_ROOT / "Makefile"
+
+
+@pytest.fixture(autouse=True)
+def _isolate_hermes_model_env(monkeypatch):
+ for key in (
+ "OPENAI_API_KEY",
+ "OPENAI_BASE_URL",
+ "OPENAI_MODEL_NAME",
+ "HERMES_CONTEXT_LENGTH",
+ "OPENAI_CONTEXT_LENGTH",
+ "MODEL_CONTEXT_LENGTH",
+ "HERMES_FALLBACK_MODEL",
+ "OPENAI_FALLBACK_MODEL_NAME",
+ "HERMES_FALLBACK_BASE_URL",
+ "API_SERVER_KEY",
+ "HERMES_API_SERVER_KEY",
+ "LANGFUSE_PUBLIC_KEY",
+ "LANGFUSE_SECRET_KEY",
+ "LANGFUSE_BASE_URL",
+ "LANGFUSE_HOST",
+ "LANGFUSE_ENV",
+ "LANGFUSE_RELEASE",
+ "HERMES_LANGFUSE_PUBLIC_KEY",
+ "HERMES_LANGFUSE_SECRET_KEY",
+ "HERMES_LANGFUSE_BASE_URL",
+ "HERMES_LANGFUSE_ENV",
+ "HERMES_LANGFUSE_RELEASE",
+ "HERMES_LANGFUSE_SAMPLE_RATE",
+ "HERMES_LANGFUSE_MAX_CHARS",
+ "HERMES_LANGFUSE_DEBUG",
+ "WPSXIEZUO_APP_ID",
+ "WPSXIEZUO_APP_KEY",
+ "WPSXIEZUO_API_BASE",
+ "WPSXIEZUO_WS_ENDPOINT",
+ "WPSXIEZUO_GROUP_AT_ONLY",
+ "WPSXIEZUO_ALLOWED_USERS",
+ "WPSXIEZUO_ALLOW_ALL_USERS",
+ "WPSXIEZUO_HOME_CHANNEL",
+ ):
+ monkeypatch.delenv(key, raising=False)
+ cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None
+ yield
+ cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None
+
+
+class _FakeHermesClient:
+ create_payload = None
+ update_payload = None
+ updated_agent_id = None
+ deleted = []
+
+ def __init__(self, *args, **kwargs):
+ self.kwargs = kwargs
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def create_agent(self, payload):
+ self.__class__.create_payload = payload
+ return {
+ "agent_id": "ar-hermes-1",
+ "name": payload["name"],
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ }
+
+ async def update_agent(self, agent_id, payload):
+ self.__class__.updated_agent_id = agent_id
+ self.__class__.update_payload = payload
+ return {
+ "agent_id": agent_id,
+ "name": "demo-hermes",
+ "endpoint": "https://hermes.example.com",
+ }
+
+ async def list_agents(self, **kwargs):
+ assert kwargs["framework"] == "hermes"
+ return {
+ "agents": [
+ {
+ "agent_id": "ar-hermes-1",
+ "name": "demo-hermes",
+ "status": "RUNNING",
+ "endpoint": "https://hermes.example.com",
+ "region": kwargs["region"],
+ }
+ ],
+ "total": 1,
+ }
+
+ async def get_client_bootstrap_config(self, **kwargs):
+ assert kwargs["product"] == "hermes"
+ assert kwargs["framework"] == "hermes"
+ return {"configs": {}}
+
+ async def get_agent(self, agent_id=None, name=None, include_api_key=False):
+ return {
+ "basic": {
+ "agent_id": agent_id or "ar-hermes-1",
+ "name": name or "demo-hermes",
+ "status": "RUNNING",
+ "framework": "hermes",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes" if include_api_key else None,
+ },
+ "advanced": {
+ "observability_url": "https://trace.example.com/project/arhermes1/traces",
+ },
+ }
+
+ async def delete_agent(self, agent_id):
+ self.__class__.deleted.append(agent_id)
+ return True
+
+
+class _FakeHermesOrderClient(_FakeHermesClient):
+ get_agent_calls = 0
+
+ async def create_agent(self, payload):
+ self.__class__.create_payload = payload
+ return {"order_id": "order-hermes-1"}
+
+ async def get_agent(self, agent_id=None, name=None, include_api_key=False):
+ self.__class__.get_agent_calls += 1
+ return {
+ "basic": {
+ "agent_id": "ar-hermes-from-order",
+ "name": name or "demo-hermes",
+ "status": "RUNNING",
+ "framework": "hermes",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://order-hermes.example.com",
+ "api_key": "ak-order-hermes",
+ },
+ }
+
+
+class _FakeHermesImmediateAgentIdClient(_FakeHermesClient):
+ get_agent_calls = 0
+
+ async def create_agent(self, payload):
+ self.__class__.create_payload = payload
+ return {
+ "agent_id": "ar-hermes-immediate",
+ "name": payload["name"],
+ "endpoint": None,
+ "api_key": None,
+ "order_id": "order-hermes-2",
+ }
+
+ async def get_agent(self, agent_id=None, name=None, include_api_key=False):
+ self.__class__.get_agent_calls += 1
+ return {
+ "basic": {
+ "agent_id": agent_id or "ar-hermes-immediate",
+ "name": name or "demo-hermes",
+ "status": "RUNNING",
+ "framework": "hermes",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://fresh-hermes.example.com",
+ "api_key": "ak-fresh-hermes" if include_api_key else None,
+ },
+ }
+
+
+class _FakeHermesDelayedAccessClient(_FakeHermesClient):
+ get_agent_calls = 0
+ suppression_used = False
+
+ async def create_agent(self, payload):
+ self.__class__.create_payload = payload
+ return {
+ "agent_id": "ar-hermes-delayed",
+ "name": payload["name"],
+ "endpoint": "https://created-hermes.example.com",
+ "api_key": None,
+ "status": 200,
+ }
+
+ @contextmanager
+ def suppress_http_error_logging(self, predicate=None):
+ self.__class__.suppression_used = predicate is not None
+ yield
+
+ async def get_agent(self, agent_id=None, name=None, include_api_key=False):
+ self.__class__.get_agent_calls += 1
+ if self.__class__.get_agent_calls < 4:
+ raise AgentEngineAPIError(
+ 404,
+ "未找到对应的 Agent",
+ details={
+ "http_status": 404,
+ "remote_error_message": "未找到对应的 Agent",
+ },
+ )
+ return {
+ "basic": {
+ "agent_id": agent_id or "ar-hermes-delayed",
+ "name": name or "demo-hermes",
+ "status": "RUNNING",
+ "framework": "hermes",
+ "region": "cn-beijing-6",
+ },
+ "quick_access": {
+ "public_endpoint": "https://ready-hermes.example.com",
+ "api_key": "ak-ready-hermes" if include_api_key else None,
+ },
+ }
+
+
+class _FakeNonHermesClient(_FakeHermesClient):
+ async def get_agent(self, agent_id=None, name=None, include_api_key=False):
+ return {
+ "basic": {
+ "agent_id": agent_id or "ar-langgraph-1",
+ "name": name or "demo-langgraph",
+ "status": "RUNNING",
+ "framework": "langgraph",
+ },
+ "quick_access": {
+ "public_endpoint": "https://langgraph.example.com",
+ },
+ }
+
+
+class _FakeHermesDryRunClient(_FakeHermesClient):
+ async def create_agent(self, payload):
+ raise DryRunExit(
+ "dry-run",
+ payload={
+ "method": "POST",
+ "url": "http://example.com/?Action=CreateAgentProduct&Version=2024-06-12",
+ "headers": {
+ "Authorization": "Bearer sk-live-secret",
+ "Content-Type": "application/json",
+ },
+ "body": {
+ "Advanced": {
+ "EnvironmentVariables": [
+ {"Key": "OPENAI_API_KEY", "Value": "sk-test-secret", "IsSensitive": True},
+ {"Key": "OPENAI_MODEL_NAME", "Value": "glm-test", "IsSensitive": False},
+ ]
+ }
+ },
+ "curl": """curl -X POST "http://example.com" \\
+ -H "Authorization: Bearer sk-live-secret" \\
+ -d '{"Advanced":{"EnvironmentVariables":[{"Key":"OPENAI_API_KEY","Value":"sk-test-secret","IsSensitive":true}]}}'""",
+ },
+ )
+
+
+class _FakeHermesBootstrapImageClient(_FakeHermesClient):
+ async def get_client_bootstrap_config(self, **kwargs):
+ assert kwargs["product"] == "hermes"
+ assert kwargs["framework"] == "hermes"
+ return {
+ "configs": {
+ "bootstrap.default_image": "registry.example.com/agentengine-public/hermes-agent:db-meta"
+ }
+ }
+
+
+def test_hermes_build_defaults_track_v2026_5_29_2_release():
+ dockerfile = HERMES_DOCKERFILE.read_text(encoding="utf-8")
+ makefile = MAKEFILE.read_text(encoding="utf-8")
+
+ assert 'ARG HERMES_AGENT_REF=v2026.5.29.2' in dockerfile
+ assert 'HERMES_TAG ?= 2026.5.29.2-ksadk-v3' in makefile
+ assert 'HERMES_AGENT_REF ?= v2026.5.29.2' in makefile
+ assert cmd_hermes.DEFAULT_HERMES_IMAGE.endswith(':2026.5.29.2-ksadk-v1')
+ assert '"langfuse>=3.9.0,<4"' in dockerfile
+
+
+def test_hermes_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path: Path):
+ runner = CliRunner()
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesImmediateAgentIdClient)
+ monkeypatch.chdir(tmp_path)
+ _FakeHermesImmediateAgentIdClient.get_agent_calls = 0
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ [
+ "deploy",
+ "--name",
+ "demo-hermes",
+ "--image",
+ "ghcr.io/kingsoftcloud/hermes-agent:test",
+ "--model-base-url",
+ "https://model.example.com/v1",
+ "--model-api-key",
+ "sk-demo",
+ "--default-model",
+ "glm-test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesImmediateAgentIdClient.get_agent_calls == 1
+ state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
+ assert "agent_id: ar-hermes-immediate" in state
+ assert "endpoint: https://fresh-hermes.example.com" in state
+ assert "api_key: ak-fresh-hermes" in state
+
+
+def test_hermes_deploy_retries_transient_get_agent_not_found_without_showing_numeric_status(
+ monkeypatch,
+ tmp_path: Path,
+):
+ runner = CliRunner()
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDelayedAccessClient)
+ monkeypatch.chdir(tmp_path)
+ _FakeHermesDelayedAccessClient.get_agent_calls = 0
+ _FakeHermesDelayedAccessClient.suppression_used = False
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ [
+ "deploy",
+ "--name",
+ "demo-hermes",
+ "--image",
+ "ghcr.io/kingsoftcloud/hermes-agent:test",
+ "--model-base-url",
+ "https://model.example.com/v1",
+ "--model-api-key",
+ "sk-demo",
+ "--default-model",
+ "glm-test",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesDelayedAccessClient.suppression_used is True
+ assert _FakeHermesDelayedAccessClient.get_agent_calls == 4
+ assert "当前状态: RUNNING" in result.output
+ assert "当前状态: 200" not in result.output
+ state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
+ assert "agent_id: ar-hermes-delayed" in state
+ assert "endpoint: https://ready-hermes.example.com" in state
+ assert "api_key: ak-ready-hermes" in state
+
+
+def test_hermes_exec_accepts_readonly_subcommand_and_uses_remote_terminal(monkeypatch):
+ runner = CliRunner()
+ captured = {}
+
+ async def _fake_exec(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
+ monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ })
+
+ result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["endpoint"] == "https://hermes.example.com"
+ assert captured["api_key"] == "ak-hermes"
+ assert captured["mode"] == "exec"
+ assert captured["argv"] == ["status"]
+
+
+def test_hermes_exec_rejects_mutating_subcommand_before_remote_call(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_exec(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec)
+
+ result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "gateway", "restart"])
+
+ assert result.exit_code != 0
+ assert "不允许" in result.output or "not allowed" in result.output
+
+
+def test_hermes_exec_exits_cleanly_on_keyboard_interrupt(monkeypatch):
+ runner = CliRunner()
+
+ def _fake_exec(**_kwargs):
+ return object()
+
+ def _raise_keyboard_interrupt(_awaitable):
+ raise KeyboardInterrupt
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
+ monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ })
+ monkeypatch.setattr(cmd_hermes.asyncio, "run", _raise_keyboard_interrupt)
+
+ result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"])
+
+ assert result.exit_code == 130
+ assert "Traceback" not in result.output
+
+
+def test_hermes_pairing_accepts_safe_subcommand_and_uses_remote_terminal(monkeypatch):
+ runner = CliRunner()
+ captured = {}
+
+ async def _fake_pairing(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ },
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["pairing", "ar-hermes-1", "--", "approve", "feishu", "ABC123"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert captured["mode"] == "pairing"
+ assert captured["argv"] == ["approve", "feishu", "ABC123"]
+
+
+def test_hermes_pairing_without_agent_ref_uses_state_resolution(monkeypatch):
+ runner = CliRunner()
+ captured = {}
+ resolved = {}
+
+ async def _fake_pairing(**kwargs):
+ captured.update(kwargs)
+
+ def _fake_resolve(**kwargs):
+ resolved.update(kwargs)
+ return {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ }
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing)
+ monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["pairing", "--", "approve", "feishu", "ABC123"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert resolved["agent_ref"] is None
+ assert captured["mode"] == "pairing"
+ assert captured["argv"] == ["approve", "feishu", "ABC123"]
+
+
+def test_hermes_pairing_rejects_invalid_platform_before_remote_call(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_pairing(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["pairing", "ar-hermes-1", "--", "approve", "unknown-platform", "ABC123"],
+ )
+
+ assert result.exit_code != 0
+ assert "不允许" in result.output or "not allowed" in result.output
+
+
+def test_hermes_exec_without_agent_ref_uses_state_resolution(monkeypatch):
+ runner = CliRunner()
+ captured = {}
+ resolved = {}
+
+ async def _fake_exec(**kwargs):
+ captured.update(kwargs)
+
+ def _fake_resolve(**kwargs):
+ resolved.update(kwargs)
+ return {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ }
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
+ monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["exec", "--", "status"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert resolved["agent_ref"] is None
+ assert captured["mode"] == "exec"
+ assert captured["argv"] == ["status"]
+
+
+def test_hermes_connect_enters_remote_gateway_setup(monkeypatch):
+ runner = CliRunner()
+ captured = {}
+
+ async def _fake_connect(**kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_connect)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: {
+ "endpoint": "https://hermes.example.com",
+ "api_key": "ak-hermes",
+ },
+ )
+
+ result = runner.invoke(cmd_hermes.hermes, ["connect", "ar-hermes-1"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["mode"] == "connect"
+ assert captured["endpoint"] == "https://hermes.example.com"
+ assert captured["api_key"] == "ak-hermes"
+
+
+def test_hermes_exec_dry_run_does_not_resolve_or_connect(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_exec(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["exec", "ar-hermes-1", "--dry-run", "--output", "json", "--", "status"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "dry_run"
+ assert payload["resource"] == "hermes"
+ assert payload["action"] == "exec"
+ assert payload["request"]["argv"] == ["status"]
+
+
+def test_hermes_connect_dry_run_does_not_resolve_or_connect(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_connect(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_connect)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["connect", "ar-hermes-1", "--dry-run", "--output", "json"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "dry_run"
+ assert payload["resource"] == "hermes"
+ assert payload["action"] == "connect"
+ assert payload["request"]["mode"] == "connect"
+
+
+def test_hermes_pairing_dry_run_does_not_resolve_or_connect(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_pairing(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "feishu", "ABC123"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "dry_run"
+ assert payload["resource"] == "hermes"
+ assert payload["action"] == "pairing"
+ assert payload["request"]["argv"] == ["approve", "feishu", "ABC123"]
+
+
+def test_hermes_pairing_dry_run_accepts_wpsxiezuo_platform(monkeypatch):
+ runner = CliRunner()
+
+ async def _forbidden_pairing(**_kwargs):
+ raise AssertionError("remote terminal should not be called")
+
+ monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_resolve_hermes_access",
+ lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "wpsxiezuo", "WPS123"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["request"]["argv"] == ["approve", "wpsxiezuo", "WPS123"]
+
+
+def test_hermes_open_defaults_to_manage_and_supports_chat_override(monkeypatch):
+ runner = CliRunner()
+ opened = []
+
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_get_hermes_detail",
+ lambda *args, **kwargs: asyncio.sleep(
+ 0,
+ result={
+ "agent_id": "ar-hermes-1",
+ "framework": "hermes",
+ },
+ ),
+ )
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_open_dashboard",
+ lambda **kwargs: opened.append(kwargs),
+ )
+
+ manage_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--no-open"])
+ chat_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--chat", "--no-open"])
+
+ assert manage_result.exit_code == 0, manage_result.output
+ assert chat_result.exit_code == 0, chat_result.output
+ assert opened[0]["ui_path"] == "/"
+ assert opened[1]["ui_path"] == "/chat"
+
+
+def test_hermes_open_force_new_forwards_to_dashboard(monkeypatch):
+ runner = CliRunner()
+ opened = []
+
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_get_hermes_detail",
+ lambda *args, **kwargs: asyncio.sleep(
+ 0,
+ result={
+ "agent_id": "ar-hermes-1",
+ "framework": "hermes",
+ },
+ ),
+ )
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_open_dashboard",
+ lambda **kwargs: opened.append(kwargs),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["open", "ar-hermes-1", "--chat", "--share", "--expires-seconds", "86400", "--force-new", "--no-open"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert opened[0]["ui_path"] == "/chat"
+ assert opened[0]["share"] is True
+ assert opened[0]["expires_seconds"] == 86400
+ assert opened[0]["force_new"] is True
+
+
+def test_hermes_open_dry_run_does_not_resolve_or_open(monkeypatch):
+ runner = CliRunner()
+
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_get_hermes_detail",
+ lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("agent detail should not be resolved")),
+ )
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_open_dashboard",
+ lambda **kwargs: (_ for _ in ()).throw(AssertionError("dashboard should not open")),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["open", "ar-hermes-1", "--chat", "--dry-run", "--output", "json"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "dry_run"
+ assert payload["action"] == "open"
+ assert payload["request"]["path"] == "/chat"
+
+
+def test_hermes_open_rejects_manage_and_chat_together(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_get_hermes_detail",
+ lambda *args, **kwargs: asyncio.sleep(
+ 0,
+ result={
+ "agent_id": "ar-hermes-1",
+ "framework": "hermes",
+ },
+ ),
+ )
+
+ result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--chat", "--no-open"])
+
+ assert result.exit_code != 0
+
+
+def test_hermes_deploy_creates_container_framework_and_persists_state(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ _FakeHermesClient.update_payload = None
+ _FakeHermesClient.updated_agent_id = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesClient.create_payload["framework"] == "hermes"
+ assert _FakeHermesClient.create_payload["artifact_type"] == "Container"
+ assert _FakeHermesClient.create_payload["artifact_path"] == "registry/hermes:test"
+ assert _FakeHermesClient.create_payload["ui_config"] == {"profile": "hermes", "path": "/", "url": None}
+ assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-test" for item in _FakeHermesClient.create_payload["env_vars"])
+ assert "agent_id: ar-hermes-1" in (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
+
+
+def test_hermes_deploy_create_payload_includes_explicit_network(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ [
+ "deploy",
+ "--name",
+ "demo-hermes",
+ "--image",
+ "registry/hermes:test",
+ "--disable-public-access",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ "--availability-zone",
+ "cn-beijing-6b",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesClient.create_payload["network"] == {
+ "enable_public_access": False,
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ "availability_zone": "cn-beijing-6b",
+ }
+
+
+def test_hermes_deploy_infers_availability_zone_from_subnet(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ monkeypatch.setattr(
+ "ksadk.cli.network_options._resolve_subnet_availability_zone",
+ lambda *, subnet_id, region: (
+ "cn-beijing-6e"
+ if subnet_id == "subnet-cli" and region == "cn-beijing-6"
+ else None
+ ),
+ )
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ [
+ "deploy",
+ "--name",
+ "demo-hermes",
+ "--region",
+ "cn-beijing-6",
+ "--image",
+ "registry/hermes:test",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesClient.create_payload["network"] == {
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ "availability_zone": "cn-beijing-6e",
+ }
+
+
+def test_hermes_deploy_omits_network_when_not_configured(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "network" not in _FakeHermesClient.create_payload
+
+
+def test_hermes_deploy_defaults_model_base_url_and_omits_api_key(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy"])
+
+ assert result.exit_code == 0, result.output
+ assert "https://kspmas.ksyun.com/v1/" in result.output
+ assert "glm-5.1" in result.output
+ assert any(
+ item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://kspmas.ksyun.com/v1/"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+ assert any(
+ item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-5.1"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+ assert not any(item["Key"] == "OPENAI_API_KEY" for item in _FakeHermesClient.create_payload["env_vars"])
+
+
+def test_hermes_deploy_reads_model_config_from_global_settings(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(
+ cmd_hermes,
+ "_get_hermes_global_env",
+ lambda: {
+ "OPENAI_API_KEY": "sk-global",
+ "OPENAI_BASE_URL": "https://model.example.com/v1",
+ "OPENAI_MODEL_NAME": "glm-global",
+ },
+ raising=False,
+ )
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
+
+ assert result.exit_code == 0, result.output
+ assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-global" for item in _FakeHermesClient.create_payload["env_vars"])
+ assert any(item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://model.example.com/v1" for item in _FakeHermesClient.create_payload["env_vars"])
+ assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-global" for item in _FakeHermesClient.create_payload["env_vars"])
+
+
+def test_hermes_deploy_defaults_kspmas_base_url_when_missing(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert "https://kspmas.ksyun.com/v1/" in result.output
+ assert any(
+ item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://kspmas.ksyun.com/v1/"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_output_json_emits_result_envelope(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test", "--output", "json"],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = json.loads(result.output)
+ assert payload["kind"] == "result"
+ assert payload["resource"] == "hermes"
+ assert payload["action"] == "deploy"
+ assert payload["result"]["id"] == "ar-hermes-1"
+ assert payload["result"]["image"] == "registry/hermes:test"
+ assert payload["result"]["framework"] == "hermes"
+ assert payload["result"]["endpoint"] == "https://hermes.example.com"
+
+
+def test_hermes_deploy_preserves_configured_public_kspmas_url(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert (
+ _FakeHermesClient.create_payload["artifact_path"]
+ == "ghcr.io/kingsoftcloud/hermes-agent:2026.5.29.2-ksadk-v1"
+ )
+ assert any(
+ item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "http://kspmas.ksyun.com/v1"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_sets_glm_51_context_length_and_default_fallback(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert any(
+ item["Key"] == "HERMES_CONTEXT_LENGTH" and item["Value"] == "200000"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+ assert any(
+ item["Key"] == "HERMES_FALLBACK_MODEL" and item["Value"] == "kimi-k2.6"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_forwards_explicit_fallback_model(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setenv("HERMES_FALLBACK_MODEL", "explicit-fallback")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]}
+ assert env_vars["HERMES_FALLBACK_MODEL"] == "explicit-fallback"
+ assert env_vars["HERMES_FALLBACK_PROVIDER"] == "custom"
+ assert env_vars["HERMES_FALLBACK_BASE_URL"] == "http://kspmas.ksyun.com/v1"
+
+
+def test_hermes_deploy_uses_provider_context_length_for_configured_model(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ async def _fake_fetch_provider_model_metadata(**_kwargs):
+ return {
+ "id": "deepseek-v4-pro",
+ "context_window_tokens": 1_000_000,
+ "max_output_tokens": 384_000,
+ }
+
+ monkeypatch.setattr(
+ cmd_hermes,
+ "fetch_provider_model_metadata",
+ _fake_fetch_provider_model_metadata,
+ )
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert any(
+ item["Key"] == "HERMES_CONTEXT_LENGTH" and item["Value"] == "1000000"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_forwards_langfuse_env_when_configured(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
+ monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
+ monkeypatch.setenv("LANGFUSE_BASE_URL", "https://langfuse.pre.example.com")
+ monkeypatch.setenv("LANGFUSE_ENV", "pre")
+ monkeypatch.setenv("HERMES_LANGFUSE_SAMPLE_RATE", "0.5")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ env_vars = {
+ item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"]
+ }
+ assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["Value"] == "pk-lf-test"
+ assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["IsSensitive"] is True
+ assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["Value"] == "sk-lf-test"
+ assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["IsSensitive"] is True
+ assert env_vars["HERMES_LANGFUSE_BASE_URL"]["Value"] == "https://langfuse.pre.example.com"
+ assert env_vars["HERMES_LANGFUSE_ENV"]["Value"] == "pre"
+ assert env_vars["HERMES_LANGFUSE_SAMPLE_RATE"]["Value"] == "0.5"
+
+
+def test_hermes_deploy_forwards_wpsxiezuo_env_when_configured(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setenv("WPSXIEZUO_APP_ID", "AK-wps-test")
+ monkeypatch.setenv("WPSXIEZUO_APP_KEY", "wps-app-key")
+ monkeypatch.setenv("WPSXIEZUO_API_BASE", "https://openapi.wps.cn")
+ monkeypatch.setenv("WPSXIEZUO_GROUP_AT_ONLY", "true")
+ monkeypatch.setenv("WPSXIEZUO_ALLOWED_USERS", "u1,u2")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ env_vars = {item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"]}
+ assert env_vars["WPSXIEZUO_APP_ID"]["Value"] == "AK-wps-test"
+ assert env_vars["WPSXIEZUO_APP_ID"]["IsSensitive"] is False
+ assert env_vars["WPSXIEZUO_APP_KEY"]["Value"] == "wps-app-key"
+ assert env_vars["WPSXIEZUO_APP_KEY"]["IsSensitive"] is True
+ assert env_vars["WPSXIEZUO_API_BASE"]["Value"] == "https://openapi.wps.cn"
+ assert env_vars["WPSXIEZUO_GROUP_AT_ONLY"]["Value"] == "true"
+ assert env_vars["WPSXIEZUO_ALLOWED_USERS"]["Value"] == "u1,u2"
+
+
+def test_hermes_deploy_defaults_ui_locale_to_zh(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
+ monkeypatch.delenv("HERMES_UI_LOCALE", raising=False)
+ monkeypatch.delenv("LANG", raising=False)
+ monkeypatch.delenv("LC_ALL", raising=False)
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert any(
+ item["Key"] == "HERMES_UI_LOCALE" and item["Value"] == "zh"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_normalizes_ui_locale_from_lang(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
+ monkeypatch.delenv("HERMES_UI_LOCALE", raising=False)
+ monkeypatch.setenv("LANG", "en_US.UTF-8")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert any(
+ item["Key"] == "HERMES_UI_LOCALE" and item["Value"] == "en"
+ for item in _FakeHermesClient.create_payload["env_vars"]
+ )
+
+
+def test_hermes_deploy_prefers_bootstrap_default_image(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesBootstrapImageClient.create_payload = None
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesBootstrapImageClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
+
+ assert result.exit_code == 0, result.output
+ assert (
+ _FakeHermesBootstrapImageClient.create_payload["artifact_path"]
+ == "registry.example.com/agentengine-public/hermes-agent:db-meta"
+ )
+
+
+def test_hermes_deploy_updates_existing_hermes_state(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ _FakeHermesClient.update_payload = None
+ _FakeHermesClient.updated_agent_id = None
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesClient.create_payload is None
+ assert _FakeHermesClient.updated_agent_id == "ar-hermes-existing"
+ assert _FakeHermesClient.update_payload["framework"] == "hermes"
+ assert _FakeHermesClient.update_payload["artifact_type"] == "Container"
+ assert _FakeHermesClient.update_payload["artifact_path"] == "registry/hermes:new"
+
+
+def test_hermes_deploy_update_payload_preserves_existing_config_by_default(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ _FakeHermesClient.update_payload = None
+ _FakeHermesClient.updated_agent_id = None
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-local-shell")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://local-shell.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "local-shell-model")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"])
+
+ assert result.exit_code == 0, result.output
+ payload = _FakeHermesClient.update_payload
+ assert payload["artifact_path"] == "registry/hermes:new"
+ assert "env_vars" not in payload
+ assert "storage" not in payload
+ assert "network" not in payload
+
+
+def test_hermes_deploy_update_payload_includes_explicit_config(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.create_payload = None
+ _FakeHermesClient.update_payload = None
+ _FakeHermesClient.updated_agent_id = None
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".agentengine.state").write_text(
+ "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(
+ cmd_hermes.hermes,
+ [
+ "deploy",
+ "--image",
+ "registry/hermes:new",
+ "--model-base-url",
+ "https://model.example.com/v1",
+ "--default-model",
+ "glm-test",
+ "--storage-size-gi",
+ "50",
+ "--enable-vpc-access",
+ "--vpc-id",
+ "vpc-cli",
+ "--subnet-id",
+ "subnet-cli",
+ "--security-group-id",
+ "sg-cli",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ payload = _FakeHermesClient.update_payload
+ assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-test" for item in payload["env_vars"])
+ assert payload["storage"]["size_gi"] == 50
+ assert payload["network"] == {
+ "enable_vpc_access": True,
+ "vpc_id": "vpc-cli",
+ "subnet_id": "subnet-cli",
+ "security_group_id": "sg-cli",
+ }
+
+
+def test_hermes_deploy_dry_run_redacts_sensitive_values(monkeypatch, tmp_path: Path):
+ runner = CliRunner()
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDryRunClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--dry-run"])
+
+ assert result.exit_code == 0, result.output
+ assert "sk-test-secret" not in result.output
+ assert "sk-live-secret" not in result.output
+ assert "***" in result.output
+ assert "glm-test" in result.output
+
+
+def test_hermes_deploy_polls_order_until_agent_access_is_available(tmp_path: Path, monkeypatch):
+ runner = CliRunner()
+ _FakeHermesOrderClient.get_agent_calls = 0
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesOrderClient)
+
+ async def _fake_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr(cmd_hermes.asyncio, "sleep", _fake_sleep)
+
+ result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
+
+ assert result.exit_code == 0, result.output
+ assert _FakeHermesOrderClient.get_agent_calls == 1
+ state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
+ assert "agent_id: ar-hermes-from-order" in state
+ assert "endpoint: https://order-hermes.example.com" in state
+ assert "api_key: ak-order-hermes" in state
+
+
+def test_hermes_list_status_and_delete_use_hermes_resource(monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.deleted = []
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
+
+ list_result = runner.invoke(cmd_hermes.hermes, ["list"])
+ status_result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
+ delete_result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
+
+ assert list_result.exit_code == 0, list_result.output
+ assert status_result.exit_code == 0, status_result.output
+ assert delete_result.exit_code == 0, delete_result.output
+ assert "ar-hermes-1" in list_result.output
+ assert "RUNNING" in status_result.output
+ assert _FakeHermesClient.deleted == ["ar-hermes-1"]
+
+
+def test_hermes_status_passes_status_style_to_descriptor(monkeypatch):
+ runner = CliRunner()
+ configure_ui_runtime(output_mode=OUTPUT_MODE_PRETTY, no_color=False, stdout_is_tty=True)
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ captured = {}
+
+ def _fake_render_descriptor_status(*args, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status)
+
+ result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["fields"][1] == ("状态", "RUNNING", status_rich_style("RUNNING"))
+
+
+def test_hermes_status_shows_langfuse_trace_url(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+
+ result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
+
+ assert result.exit_code == 0, result.output
+ assert "Langfuse" in result.output
+ assert "https://trace.example.com/project/arhermes1/traces" in result.output
+
+
+def test_hermes_delete_uses_delete_specific_next_steps(monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.deleted = []
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
+
+ result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
+
+ assert result.exit_code == 0, result.output
+ assert "agentengine hermes list" in result.output
+ assert "agentengine hermes deploy" in result.output
+ assert "agentengine hermes connect" not in result.output
+ assert "agentengine hermes pairing" not in result.output
+
+
+def test_hermes_delete_passes_result_styles_to_descriptor(monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.deleted = []
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
+ captured = {}
+
+ def _fake_render_descriptor_status(*args, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status)
+
+ result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
+
+ assert result.exit_code == 0, result.output
+ assert captured["fields"][1] == ("已删除", "ar-hermes-1", "ok")
+ assert captured["fields"][2] == ("失败", "-", "muted")
+
+
+def test_hermes_delete_resolves_name_to_agent_id_and_rejects_non_hermes(monkeypatch):
+ runner = CliRunner()
+ _FakeHermesClient.deleted = []
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
+ monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
+
+ delete_by_name = runner.invoke(cmd_hermes.hermes, ["delete", "demo-hermes", "-y"])
+
+ assert delete_by_name.exit_code == 0, delete_by_name.output
+ assert _FakeHermesClient.deleted == ["ar-hermes-1"]
+
+ _FakeHermesClient.deleted = []
+ monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeNonHermesClient)
+
+ non_hermes = runner.invoke(cmd_hermes.hermes, ["delete", "ar-langgraph-1", "-y"])
+
+ assert non_hermes.exit_code != 0
+ assert _FakeHermesClient.deleted == []
diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py
new file mode 100644
index 0000000..5321c66
--- /dev/null
+++ b/tests/test_langgraph_runner_resume.py
@@ -0,0 +1,681 @@
+from types import SimpleNamespace
+
+import pytest
+from langgraph.types import Command
+import base64
+
+from ksadk.runners.langgraph_runner import LangGraphRunner
+
+
+class _DummyAgent:
+ def __init__(self):
+ self.last_ainvoke_state = None
+ self.last_astream_state = None
+ self.last_ainvoke_context = None
+
+ async def ainvoke(self, state, config=None, context=None):
+ self.last_ainvoke_state = state
+ self.last_ainvoke_context = context
+ return {"messages": [{"content": "ok"}]}
+
+ async def astream_events(self, state, version="v2", config=None):
+ self.last_astream_state = state
+ if False:
+ yield {}
+
+
+class _Chunk:
+ def __init__(self, content="", reasoning_content=None):
+ self.content = content
+ self.additional_kwargs = {}
+ if reasoning_content is not None:
+ self.additional_kwargs["reasoning_content"] = reasoning_content
+
+
+class _StreamingAgent(_DummyAgent):
+ async def astream_events(self, state, version="v2", config=None):
+ self.last_astream_state = state
+ yield {
+ "event": "on_chat_model_stream",
+ "data": {"chunk": _Chunk(reasoning_content="先分析需求。")},
+ }
+ yield {
+ "event": "on_chat_model_stream",
+ "data": {"chunk": _Chunk(content="这是最终回复。")},
+ }
+
+
+class _DuplicatedReasoningStreamingAgent(_DummyAgent):
+ async def astream_events(self, state, version="v2", config=None):
+ self.last_astream_state = state
+ yield {
+ "event": "on_chat_model_stream",
+ "data": {
+ "chunk": _Chunk(
+ content="先分析需求。",
+ reasoning_content="先分析需求。",
+ )
+ },
+ }
+ yield {
+ "event": "on_chat_model_stream",
+ "data": {"chunk": _Chunk(content="这是最终回复。")},
+ }
+
+
+class _ToolDictOutputStreamingAgent(_DummyAgent):
+ async def astream_events(self, state, version="v2", config=None):
+ self.last_astream_state = state
+ yield {
+ "event": "on_tool_end",
+ "name": "write_workspace_file",
+ "run_id": "run-approval",
+ "data": {
+ "output": {
+ "ok": False,
+ "type": "approval_required",
+ "approval_request": {
+ "id": "appr_write",
+ "tool_name": "write_workspace_file",
+ },
+ }
+ },
+ }
+
+
+class _ToolThenAnswerStreamingAgent(_DummyAgent):
+ async def astream_events(self, state, version="v2", config=None):
+ self.last_astream_state = state
+ yield {
+ "event": "on_tool_start",
+ "name": "list_skills",
+ "run_id": "run-list-skills",
+ "data": {"input": {}},
+ }
+ yield {
+ "event": "on_tool_end",
+ "name": "list_skills",
+ "run_id": "run-list-skills",
+ "data": {"output": {"ok": True, "skills": [{"name": "ppt-translator"}]}},
+ }
+ yield {
+ "event": "on_chain_end",
+ "name": "LangGraph",
+ "data": {
+ "output": {
+ "answer": "已真实调用 `list_skills`。\n当前返回的 Skill:\n- ppt-translator",
+ "messages": [{"content": ""}],
+ }
+ },
+ }
+
+
+def _make_runner(module=None) -> LangGraphRunner:
+ detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent")
+ runner = LangGraphRunner(detection, ".")
+ runner._agent = _DummyAgent()
+ if module is not None:
+ runner._module = module
+ return runner
+
+
+def _make_streaming_runner() -> LangGraphRunner:
+ runner = _make_runner()
+ runner._agent = _StreamingAgent()
+ return runner
+
+
+def _make_duplicated_reasoning_streaming_runner() -> LangGraphRunner:
+ runner = _make_runner()
+ runner._agent = _DuplicatedReasoningStreamingAgent()
+ return runner
+
+
+def _make_tool_dict_output_streaming_runner() -> LangGraphRunner:
+ runner = _make_runner()
+ runner._agent = _ToolDictOutputStreamingAgent()
+ return runner
+
+
+def _make_tool_then_answer_streaming_runner() -> LangGraphRunner:
+ runner = _make_runner()
+ runner._agent = _ToolThenAnswerStreamingAgent()
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_invoke_simplified_input_preserves_extra_state():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "hello",
+ "history": [{"role": "user", "content": "prev"}],
+ "files": [{"name": "resume.txt"}],
+ }
+ )
+
+ state = runner._agent.last_ainvoke_state
+ assert "messages" in state
+ assert "files" in state
+ assert state["files"] == [{"name": "resume.txt"}]
+ assert len(state["messages"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_invoke_simplified_input_does_not_duplicate_current_user_message_when_history_contains_it():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "hello",
+ "history": [{"role": "user", "content": "hello"}],
+ }
+ )
+
+ messages = runner._agent.last_ainvoke_state["messages"]
+ user_messages = [
+ message
+ for message in messages
+ if message.__class__.__name__ == "HumanMessage" and message.content == "hello"
+ ]
+ assert len(user_messages) == 1
+
+
+@pytest.mark.asyncio
+async def test_invoke_simplified_input_preserves_attachment_contract_fields():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "请分析附件",
+ "history": [{"role": "user", "content": "上一轮"}],
+ "input_parts": [{"text": "请分析附件"}],
+ "attachments": [{"display_name": "resume.pdf"}],
+ "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
+ }
+ )
+
+ state = runner._agent.last_ainvoke_state
+ assert state["input_parts"] == [{"text": "请分析附件"}]
+ assert state["attachments"] == [{"display_name": "resume.pdf"}]
+ assert state["attachment_results"] == [{"display_name": "resume.pdf", "kind": "document"}]
+ assert len(state["messages"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_stream_resume_uses_command():
+ runner = _make_runner()
+
+ chunks = [
+ chunk
+ async for chunk in runner.stream(
+ {
+ "session_id": "s1",
+ "resume": True,
+ "input": {"approved": True},
+ }
+ )
+ ]
+
+ assert isinstance(runner._agent.last_astream_state, Command)
+ assert runner._agent.last_astream_state.resume == {"approved": True}
+ assert isinstance(runner._agent.last_ainvoke_state, Command)
+ assert runner._agent.last_ainvoke_state.resume == {"approved": True}
+ assert chunks and chunks[-1]["type"] == "final"
+
+
+@pytest.mark.asyncio
+async def test_stream_does_not_mix_reasoning_into_final_text():
+ runner = _make_streaming_runner()
+
+ chunks = [
+ chunk
+ async for chunk in runner.stream(
+ {
+ "session_id": "s1",
+ "input": "写一个python快排的示例",
+ }
+ )
+ ]
+
+ assert chunks == [
+ {"delta": "先分析需求。", "type": "thinking"},
+ {"delta": "这是最终回复。", "type": "text"},
+ ]
+ assert all("先分析需求。" not in chunk.get("delta", "") for chunk in chunks if chunk["type"] == "text")
+
+
+@pytest.mark.asyncio
+async def test_stream_ignores_content_when_chunk_duplicates_reasoning():
+ runner = _make_duplicated_reasoning_streaming_runner()
+
+ chunks = [
+ chunk
+ async for chunk in runner.stream(
+ {
+ "session_id": "s1",
+ "input": "写一个python快排的示例",
+ }
+ )
+ ]
+
+ assert chunks == [
+ {"delta": "先分析需求。", "type": "thinking"},
+ {"delta": "这是最终回复。", "type": "text"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_preserves_dict_tool_output_for_gateway_approval_bridge():
+ runner = _make_tool_dict_output_streaming_runner()
+
+ chunks = [
+ chunk
+ async for chunk in runner.stream(
+ {
+ "session_id": "s1",
+ "input": "写文件",
+ }
+ )
+ ]
+
+ assert chunks == [
+ {
+ "type": "tool_result",
+ "tool_name": "write_workspace_file",
+ "tool_args": {},
+ "tool_output": {
+ "ok": False,
+ "type": "approval_required",
+ "approval_request": {
+ "id": "appr_write",
+ "tool_name": "write_workspace_file",
+ },
+ },
+ "run_id": "run-approval",
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_emits_final_answer_after_tool_events_without_text_stream():
+ runner = _make_tool_then_answer_streaming_runner()
+
+ chunks = [
+ chunk
+ async for chunk in runner.stream(
+ {
+ "session_id": "s1",
+ "input": "你有哪些 skill",
+ }
+ )
+ ]
+
+ assert chunks[-1] == {
+ "type": "final",
+ "output": "已真实调用 `list_skills`。\n当前返回的 Skill:\n- ppt-translator",
+ }
+ assert [chunk["type"] for chunk in chunks] == ["tool_call", "tool_result", "final"]
+
+
+@pytest.mark.asyncio
+async def test_invoke_with_binary_attachment_does_not_convert_reference_to_image_url():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "分析压缩包",
+ "attachments": [
+ {
+ "display_name": "bundle.zip",
+ "mime_type": "application/zip",
+ "transport": "reference",
+ "file_uri": "ksadk-upload://abc123",
+ "storage_path": "/tmp/abc123.zip",
+ }
+ ],
+ }
+ )
+
+ content = runner._agent.last_ainvoke_state["messages"][-1].content
+ if isinstance(content, list):
+ assert not any(item.get("type") == "image_url" for item in content if isinstance(item, dict))
+ else:
+ assert content == "分析压缩包"
+
+
+@pytest.mark.asyncio
+async def test_invoke_with_image_attachment_converts_to_multimodal_human_message(tmp_path):
+ runner = _make_runner()
+ image_path = tmp_path / "diagram.png"
+ image_bytes = b"\x89PNG\r\n\x1a\nfake-image"
+ image_path.write_bytes(image_bytes)
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "请分析这张图片",
+ "model_metadata": {
+ "id": "kimi-k2.6",
+ "architecture": {"input_modalities": ["文字", "图片"]},
+ },
+ "attachments": [
+ {
+ "display_name": "diagram.png",
+ "mime_type": "image/png",
+ "transport": "reference",
+ "file_uri": "ksadk-upload://img123",
+ "storage_path": str(image_path),
+ }
+ ],
+ }
+ )
+
+ content = runner._agent.last_ainvoke_state["messages"][-1].content
+ assert isinstance(content, list)
+ assert content[0] == {"type": "text", "text": "请分析这张图片"}
+ assert content[1]["type"] == "image_url"
+ assert content[1]["image_url"]["url"] == (
+ "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii")
+ )
+
+
+@pytest.mark.asyncio
+async def test_invoke_with_inline_image_attachment_converts_to_multimodal_human_message():
+ runner = _make_runner()
+ image_b64 = base64.b64encode(b"fake-inline-image").decode("ascii")
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "请看图",
+ "model_metadata": {
+ "id": "kimi-k2.6",
+ "architecture": {"input_modalities": ["文字", "图片"]},
+ },
+ "attachments": [
+ {
+ "display_name": "photo.jpg",
+ "mime_type": "image/jpeg",
+ "transport": "inline",
+ "data": image_b64,
+ }
+ ],
+ }
+ )
+
+ content = runner._agent.last_ainvoke_state["messages"][-1].content
+ assert isinstance(content, list)
+ assert content[0] == {"type": "text", "text": "请看图"}
+ assert content[1] == {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
+ }
+
+
+@pytest.mark.asyncio
+async def test_invoke_with_remote_image_attachment_preserves_image_url_for_multimodal_model():
+ runner = _make_runner()
+ image_url = "https://example.com/photo.png"
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "请看图",
+ "model_metadata": {
+ "id": "kimi-k2.6",
+ "architecture": {"input_modalities": ["文字", "图片"]},
+ },
+ "attachments": [
+ {
+ "display_name": "photo.png",
+ "mime_type": "image/*",
+ "transport": "reference",
+ "file_uri": image_url,
+ }
+ ],
+ }
+ )
+
+ content = runner._agent.last_ainvoke_state["messages"][-1].content
+ assert isinstance(content, list)
+ assert content[0] == {"type": "text", "text": "请看图"}
+ assert content[1] == {
+ "type": "image_url",
+ "image_url": {"url": image_url},
+ }
+
+
+@pytest.mark.asyncio
+async def test_invoke_with_image_attachment_keeps_image_block_even_when_catalog_is_stale(tmp_path):
+ runner = _make_runner()
+ image_path = tmp_path / "diagram.png"
+ image_bytes = b"\x89PNG\r\n\x1a\nfake-image"
+ image_path.write_bytes(image_bytes)
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "请分析这张图片",
+ "model_metadata": {
+ "id": "glm-5.1",
+ "architecture": {"input_modalities": ["文字"]},
+ },
+ "attachments": [
+ {
+ "display_name": "diagram.png",
+ "mime_type": "image/png",
+ "transport": "reference",
+ "file_uri": "ksadk-upload://img123",
+ "storage_path": str(image_path),
+ }
+ ],
+ }
+ )
+
+ content = runner._agent.last_ainvoke_state["messages"][-1].content
+ assert isinstance(content, list)
+ assert content[0] == {"type": "text", "text": "请分析这张图片"}
+ assert content[1]["type"] == "image_url"
+ assert content[1]["image_url"]["url"] == (
+ "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii")
+ )
+
+
+def test_extract_output_prefers_explicit_output_over_messages_tail():
+ runner = _make_runner()
+
+ output = runner._extract_output(
+ {
+ "output": "业务最终回答",
+ "messages": [{"role": "system", "content": "系统提示词"}],
+ }
+ )
+
+ assert output == "业务最终回答"
+
+
+def test_extract_output_uses_langgraph_answer_field():
+ runner = _make_runner()
+
+ output = runner._extract_output(
+ {
+ "answer": "业务最终回答",
+ "messages": [{"role": "assistant", "content": ""}],
+ }
+ )
+
+ assert output == "业务最终回答"
+
+
+@pytest.mark.asyncio
+async def test_invoke_simplified_input_prepends_ambient_kb_and_memory_context():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "继续回答",
+ "kb_context": {"formatted_text": "知识库: 当前支持标准型实例"},
+ "memory_context": {"formatted_text": "记忆: 用户关注机型价格"},
+ "platform_context": {"agent_id": "demo-agent", "user_id": "user-1"},
+ }
+ )
+
+ state = runner._agent.last_ainvoke_state
+ assert "messages" in state
+ assert state["messages"][0].__class__.__name__ == "SystemMessage"
+ assert "知识库: 当前支持标准型实例" in state["messages"][0].content
+ assert "记忆: 用户关注机型价格" in state["messages"][0].content
+ assert state["messages"][-1].content == "继续回答"
+ assert runner._agent.last_ainvoke_context == {
+ "agent_id": "demo-agent",
+ "user_id": "user-1",
+ }
+
+
+@pytest.mark.asyncio
+async def test_invoke_messages_payload_injects_system_context_message():
+ runner = _make_runner()
+
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "messages": [],
+ "kb_context": {"formatted_text": "KB facts"},
+ "memory_context": {"formatted_text": "Memory facts"},
+ }
+ )
+
+ state = runner._agent.last_ainvoke_state
+ assert len(state["messages"]) == 1
+ first = state["messages"][0]
+ assert first.__class__.__name__ == "SystemMessage"
+ assert "KB facts" in first.content
+ assert "Memory facts" in first.content
+
+
+# ---- ksadk_prepare_state hook tests ----
+
+
+@pytest.mark.asyncio
+async def test_invoke_uses_ksadk_prepare_state_hook():
+ def ksadk_prepare_state(payload, session_context):
+ return {
+ "query": payload["input"],
+ "results": [],
+ "session_id": session_context["session_id"],
+ }
+
+ runner = _make_runner(module=SimpleNamespace(ksadk_prepare_state=ksadk_prepare_state))
+ await runner.invoke({"session_id": "s1", "input": "hello"})
+
+ state = runner._agent.last_ainvoke_state
+ assert state == {"query": "hello", "results": [], "session_id": "s1"}
+ assert "messages" not in state
+
+
+@pytest.mark.asyncio
+async def test_invoke_prepare_state_hook_receives_kb_and_memory_context():
+ captured = []
+
+ def ksadk_prepare_state(payload, session_context):
+ captured.append((payload, session_context))
+ return {"query": payload["input"]}
+
+ runner = _make_runner(module=SimpleNamespace(ksadk_prepare_state=ksadk_prepare_state))
+ await runner.invoke({
+ "session_id": "s1",
+ "input": "search",
+ "kb_context": {"formatted_text": "KB facts"},
+ "memory_context": {"formatted_text": "Memory facts"},
+ "platform_context": {"agent_id": "a1", "user_id": "u1"},
+ })
+
+ payload, session_context = captured[0]
+ assert payload == {"input": "search"}
+ assert session_context["kb_context"] == {"formatted_text": "KB facts"}
+ assert session_context["memory_context"] == {"formatted_text": "Memory facts"}
+ assert session_context["platform_context"] == {"agent_id": "a1", "user_id": "u1"}
+ assert session_context["is_resume"] is False
+ state = runner._agent.last_ainvoke_state
+ assert state == {"query": "search"}
+
+
+@pytest.mark.asyncio
+async def test_invoke_prepare_state_hook_receives_full_normalized_payload():
+ captured = []
+
+ def ksadk_prepare_state(payload, session_context):
+ captured.append((payload, session_context))
+ return {"query": payload["input"], "files": payload["files"]}
+
+ runner = _make_runner(module=SimpleNamespace(ksadk_prepare_state=ksadk_prepare_state))
+ await runner.invoke(
+ {
+ "session_id": "s1",
+ "input": "search",
+ "history": [{"role": "user", "content": "old"}],
+ "files": [{"name": "a.txt"}],
+ "attachments": [{"display_name": "a.txt"}],
+ "platform_context": {"agent_id": "a1"},
+ }
+ )
+
+ payload, session_context = captured[0]
+ assert payload["input"] == "search"
+ assert payload["files"] == [{"name": "a.txt"}]
+ assert payload["attachments"] == [{"display_name": "a.txt"}]
+ assert "session_id" not in payload
+ assert "history" not in payload
+ assert "platform_context" not in payload
+ assert session_context["history"] == [{"role": "user", "content": "old"}]
+ assert runner._agent.last_ainvoke_state == {"query": "search", "files": [{"name": "a.txt"}]}
+
+
+@pytest.mark.asyncio
+async def test_invoke_resume_with_prepare_state_hook():
+ captured = []
+
+ def ksadk_prepare_state(payload, session_context):
+ captured.append(session_context)
+ return {
+ "approved": payload["input"].get("approved", False),
+ "comment": payload["input"].get("comment", ""),
+ }
+
+ runner = _make_runner(module=SimpleNamespace(ksadk_prepare_state=ksadk_prepare_state))
+ await runner.invoke({
+ "session_id": "s1",
+ "resume": True,
+ "input": {"approved": True, "comment": "looks good"},
+ })
+
+ state = runner._agent.last_ainvoke_state
+ assert isinstance(state, Command)
+ assert state.resume == {"approved": True, "comment": "looks good"}
+ assert captured[0]["is_resume"] is True
+
+
+@pytest.mark.asyncio
+async def test_invoke_without_hook_uses_to_state():
+ runner = _make_runner()
+ await runner.invoke({"session_id": "s1", "input": "hello"})
+ state = runner._agent.last_ainvoke_state
+ assert "messages" in state
+ assert state["messages"][-1].content == "hello"
+
+
+@pytest.mark.asyncio
+async def test_invoke_hook_returns_non_dict_raises_type_error():
+ def ksadk_prepare_state(payload, session_context):
+ return "not a dict"
+
+ runner = _make_runner(module=SimpleNamespace(ksadk_prepare_state=ksadk_prepare_state))
+ with pytest.raises(TypeError, match="ksadk_prepare_state"):
+ await runner.invoke({"session_id": "s1", "input": "hello"})
diff --git a/tests/test_openclaw_bootstrap_secretref.py b/tests/test_openclaw_bootstrap_secretref.py
new file mode 100644
index 0000000..1d3b7a2
--- /dev/null
+++ b/tests/test_openclaw_bootstrap_secretref.py
@@ -0,0 +1,4067 @@
+import json
+import os
+import subprocess
+import time
+import base64
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BOOTSTRAP_SCRIPT = REPO_ROOT / "deploy" / "openclaw" / "bootstrap.sh"
+OPENCLAW_DOCKERFILE = REPO_ROOT / "deploy" / "openclaw" / "Dockerfile"
+LATEST_OPENCLAW_BASE_IMAGE = (
+ "ghcr.io/openclaw/openclaw:2026.6.1-slim@"
+ "sha256:a83ee8716ab191534952299fe989374d75593aa9c7632c4e756e9d64b0ce8061"
+)
+VALID_MEM0_UUID = "e52b7fac-e641-4b34-b9f7-6b0b9f190cd4"
+
+
+def _write_weixin_plugin_package_json(
+ plugin_root: Path,
+ *,
+ version: str = "2.1.7",
+ package_name: str = "@tencent-weixin/openclaw-weixin",
+) -> None:
+ plugin_root.mkdir(parents=True, exist_ok=True)
+ (plugin_root / "package.json").write_text(
+ json.dumps(
+ {
+ "name": package_name,
+ "version": version,
+ }
+ )
+ + "\n"
+ )
+
+
+def _compute_directory_signature(dir_path: Path) -> str:
+ result = subprocess.run(
+ [
+ "bash",
+ "-lc",
+ r'''dir_path="$1"
+find "$dir_path" \( -type f -o -type l \) | LC_ALL=C sort | while IFS= read -r file_path; do
+ rel_path="${file_path#"$dir_path/"}"
+ if [[ -L "$file_path" ]]; then
+ printf 'link\t%s\t%s\n' "$rel_path" "$(readlink "$file_path")"
+ continue
+ fi
+ printf 'file\t%s\t' "$rel_path"
+ cksum "$file_path" | awk '{print $1 "\t" $2}'
+done | cksum | awk '{print $1 ":" $2}'
+''',
+ "_",
+ str(dir_path),
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return result.stdout.strip()
+
+
+def _build_base_env(state_dir: str, config_path: str) -> dict:
+ env = os.environ.copy()
+ for key in (
+ "OPENCLAW_DEFAULT_MODEL",
+ "OPENAI_MODEL_NAME",
+ "MODEL_NAME",
+ "LLM_MODEL",
+ "OPENCLAW_MODEL_CATALOG_JSON",
+ "OPENCLAW_MODEL_PROVIDER_ID",
+ "OPENCLAW_MODEL_BASE_URL",
+ "OPENCLAW_MODEL_API",
+ "OPENCLAW_MODEL_API_KEY",
+ "OPENAI_API_KEY",
+ "LLM_API_KEY",
+ "MODEL_API_KEY",
+ "LANGFUSE_PUBLIC_KEY",
+ "LANGFUSE_SECRET_KEY",
+ "LANGFUSE_BASE_URL",
+ "LANGFUSE_HOST",
+ "OTEL_SERVICE_NAME",
+ "OTEL_RESOURCE_ATTRIBUTES",
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
+ ):
+ env.pop(key, None)
+ safe_bin_dir = Path(state_dir) / "safe-bin"
+ raw_bin_dir = Path(state_dir) / "bin"
+ workspace_template_dir = Path(state_dir) / "workspace-template"
+ safe_bin_dir.mkdir(parents=True, exist_ok=True)
+ raw_bin_dir.mkdir(parents=True, exist_ok=True)
+ workspace_template_dir.mkdir(parents=True, exist_ok=True)
+ for cmd in ["pwd", "ls", "whoami", "id", "uname", "date", "ps", "df", "du", "stat", "find", "cat", "head", "tail", "wc", "git", "mcporter", "sh-safe", "bash-safe"]:
+ wrapper_path = safe_bin_dir / cmd
+ wrapper_path.write_text("#!/bin/sh\nexit 0\n")
+ wrapper_path.chmod(0o755)
+ for cmd in ["curl", "jq", "yt-dlp", "openclaw", "agent-browser", "gh", "xreach"]:
+ raw_bin_path = raw_bin_dir / cmd
+ raw_bin_path.write_text("#!/bin/sh\nexit 0\n")
+ raw_bin_path.chmod(0o755)
+ (workspace_template_dir / "SOUL.md").write_text("security soul\n")
+ (workspace_template_dir / "AGENTS.md").write_text("security agents\n")
+ (workspace_template_dir / "MEMORY.md").write_text("persistent memory\n")
+ (workspace_template_dir / "USER.MD").write_text("user preferences\n")
+ (workspace_template_dir / "TOOLS.md").write_text("tool notes\n")
+ env.pop("OPENCLAW_MODEL_API_KEY", None)
+ env.pop("OPENAI_API_KEY", None)
+ env["HOME"] = state_dir
+ env["OPENCLAW_STATE_DIR"] = state_dir
+ env["OPENCLAW_CONFIG_PATH"] = config_path
+ env["OPENCLAW_BOOTSTRAP_ONLY"] = "1"
+ env["OPENCLAW_MODEL_PROVIDER_ID"] = "ksyun"
+ env["OPENCLAW_MODEL_BASE_URL"] = "http://example.test/v1"
+ env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1"
+ env["OPENCLAW_SAFE_BIN_DIR"] = str(safe_bin_dir)
+ env["OPENCLAW_WORKSPACE_TEMPLATE_DIR"] = str(workspace_template_dir)
+ env["PATH"] = f"{raw_bin_dir}:{env['PATH']}"
+ return env
+
+
+def _build_mem0_manifest_json() -> str:
+ return json.dumps(
+ {
+ "schema_version": "v1",
+ "backend_type": "mem0",
+ "config": {
+ "mem0_instance_id": VALID_MEM0_UUID,
+ "mem0_region": "cn-qingyangtest-1",
+ },
+ "secrets_env": {
+ "api_key": "MEM0_API_KEY",
+ "user_id": "MEM0_USER_ID",
+ "base_url": "MEM0_BASE_URL",
+ },
+ }
+ )
+
+
+def _build_openclaw_default_memory_manifest_json() -> str:
+ return json.dumps(
+ {
+ "schema_version": "v1",
+ "backend_type": "openclaw_default",
+ }
+ )
+
+
+def _assert_model_token_defaults(models: list[dict], *, minimum_max_tokens: int = 20000) -> None:
+ for model in models:
+ if "contextWindow" not in model and "maxTokens" not in model:
+ continue
+ assert model["contextWindow"] == 200000
+ assert model["maxTokens"] >= minimum_max_tokens
+
+
+def test_openclaw_dockerfile_tracks_latest_official_channel_plugins():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+
+ assert (
+ f"ARG OPENCLAW_BASE_IMAGE={LATEST_OPENCLAW_BASE_IMAGE}"
+ in dockerfile
+ )
+ assert "ARG OPENCLAW_WEIXIN_PLUGIN_SPEC=@tencent-weixin/openclaw-weixin" in dockerfile
+ assert "ARG OPENCLAW_LARK_PLUGIN_SPEC=@larksuite/openclaw-lark" in dockerfile
+ assert "ARG OPENCLAW_MEM0_PLUGIN_ID=openclaw-mem0" in dockerfile
+ assert "ARG OPENCLAW_MEM0_PLUGIN_URL=https://memory-engine.ks3-cn-beijing.ksyuncs.com/ksc-openclaw-mem0-1.0.6.tgz" in dockerfile
+ assert "ksc-openclaw-mem0-1.1." not in dockerfile
+ assert "ARG OPENCLAW_INSTALL_WPS_XIEZUO_PLUGIN=true" in dockerfile
+ assert "ARG OPENCLAW_WPS_XIEZUO_PLUGIN_SPEC=@wps365/openclaw-wpsxiezuo" in dockerfile
+ assert "ARG OPENCLAW_WPS_XIEZUO_PLUGIN_ID=wps-xiezuo" in dockerfile
+ assert "ARG OPENCLAW_INSTALL_DIAGNOSTICS_OTEL_PLUGIN=true" in dockerfile
+ assert "ARG OPENCLAW_DIAGNOSTICS_OTEL_PLUGIN_SPEC=@openclaw/diagnostics-otel" in dockerfile
+ assert "ARG OPENCLAW_DIAGNOSTICS_OTEL_PLUGIN_ID=diagnostics-otel" in dockerfile
+ assert "openclaw-wps-xiezuo-1.6.0.tgz" not in dockerfile
+ assert "deploy/openclaw/wps-xiezuo-assets" not in dockerfile
+ assert 'install_default_plugin "${OPENCLAW_WPS_XIEZUO_PLUGIN_SPEC}" "${OPENCLAW_WPS_XIEZUO_PLUGIN_ID}"' in dockerfile
+
+
+def test_openclaw_dockerfile_moves_apt_archives_out_of_var_cache():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+
+ assert "mkdir -p /tmp/apt-cache" in dockerfile
+ assert "Dir::Cache::archives=/tmp/apt-cache" in dockerfile
+ assert "rm -rf /var/lib/apt/lists/* /tmp/apt-cache" in dockerfile
+
+
+def test_openclaw_dockerfile_strips_workspace_dev_dependencies_before_plugin_install():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+
+ assert 'spec.startsWith("workspace:")' in dockerfile
+ assert "delete deps[name]" in dockerfile
+ assert 'npm install --omit=dev --no-audit --no-fund --registry "${NPM_REGISTRY}"' in dockerfile
+ assert 'ln -s /app "${src_dir}/node_modules/openclaw"' in dockerfile
+
+
+def test_openclaw_dockerfile_installs_local_plugin_archives_without_force_flag_for_2026_3_28_compatibility():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+
+ assert "compatible with upstream OpenClaw 2026.3.28" in dockerfile
+ assert 'openclaw plugins install "${archive_path}"; \\' in dockerfile
+ assert 'openclaw plugins install "${archive_path}" --force; \\' not in dockerfile
+
+
+def test_openclaw_runtime_bundles_runtime_common_and_manifest_renderer():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+ bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8")
+
+ assert "COPY ksadk_runtime_common /opt/ksadk_runtime_common" in dockerfile
+ assert "COPY deploy/openclaw/workspace_files_app.py /opt/openclaw/workspace_files_app.py" in dockerfile
+ assert '"fastapi>=0.100.0,<0.124.0"' in dockerfile
+ assert '"httpx>=0.24.0,<1.0.0"' in dockerfile
+ assert '"uvicorn>=0.23.0,<1.0.0"' in dockerfile
+ assert '"websockets>=11.0.0,<16.0.0"' in dockerfile
+ assert '"python-multipart>=0.0.9,<1.0.0"' in dockerfile
+ assert "PYTHONPATH=/opt" in dockerfile
+ assert "from ksadk_runtime_common.memory_backend.render import render_to_json" in bootstrap
+ assert 'uvicorn workspace_files_app:app \\' in bootstrap
+ assert 'OPENCLAW_WORKSPACE_FILES_PROXY_URL' in bootstrap
+
+
+def test_openclaw_dockerfile_clones_official_kdocs_skill_and_normalizes_runtime_layout():
+ dockerfile = OPENCLAW_DOCKERFILE.read_text(encoding="utf-8")
+
+ assert "ARG KDOCS_SKILL_REPO=https://github.com/kdocs-app/kdocs-skill.git" in dockerfile
+ assert 'git clone --depth 1 "${KDOCS_SKILL_REPO}" /tmp/kdocs-skill' in dockerfile
+ assert 'mkdir -p /opt/openclaw/preset-skills/kdocs/scripts' in dockerfile
+ assert 'cp -R /tmp/kdocs-skill/. /opt/openclaw/preset-skills/kdocs/' in dockerfile
+ assert 'printf \'%s\\n\' \\' in dockerfile
+ assert 'exec bash "${SCRIPT_DIR}/scripts/setup.sh" "$@"' in dockerfile
+
+
+def test_bootstrap_writes_secretref_for_model_api_key():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ secrets_path = Path(tmpdir) / "secrets.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["models"]["providers"]["ksyun"]["apiKey"] == {
+ "source": "file",
+ "provider": "default",
+ "id": "/providers/ksyun/apiKey",
+ }
+ assert cfg["secrets"]["providers"]["default"] == {
+ "source": "file",
+ "path": str(secrets_path),
+ "mode": "json",
+ }
+ assert cfg["secrets"]["defaults"]["file"] == "default"
+ assert json.loads(secrets_path.read_text()) == {
+ "providers": {
+ "ksyun": {
+ "apiKey": "dummy-secret-value",
+ }
+ }
+ }
+ assert secrets_path.stat().st_mode & 0o777 == 0o600
+
+
+def test_bootstrap_applies_openclaw_config_patch_json():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "plugins": {
+ "allow": ["existing-plugin"],
+ "entries": {"existing-plugin": {"enabled": True}},
+ },
+ "diagnostics": {"enabled": False},
+ }
+ )
+ + "\n"
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_CONFIG_PATCH_JSON"] = json.dumps(
+ {
+ "plugins": {
+ "allow": ["diagnostics-otel"],
+ "entries": {"diagnostics-otel": {"enabled": True}},
+ },
+ "diagnostics": {
+ "enabled": True,
+ "otel": {
+ "enabled": True,
+ "endpoint": "https://langfuse.pre.example.com/api/public/otel",
+ "protocol": "http/protobuf",
+ "serviceName": "agentengine-openclaw-demo",
+ "traces": True,
+ "metrics": False,
+ "logs": False,
+ "captureContent": False,
+ },
+ },
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["plugins"]["entries"]["existing-plugin"]["enabled"] is True
+ assert cfg["plugins"]["entries"]["diagnostics-otel"]["enabled"] is True
+ assert "diagnostics-otel" in cfg["plugins"]["allow"]
+ assert cfg["diagnostics"]["enabled"] is True
+ assert cfg["diagnostics"]["otel"] == {
+ "enabled": True,
+ "endpoint": "https://langfuse.pre.example.com/api/public/otel",
+ "protocol": "http/protobuf",
+ "serviceName": "agentengine-openclaw-demo",
+ "traces": True,
+ "metrics": False,
+ "logs": False,
+ "captureContent": False,
+ }
+
+
+def test_bootstrap_migrates_legacy_diagnostics_capture_content_to_otel():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_CONFIG_PATCH_JSON"] = json.dumps(
+ {
+ "diagnostics": {
+ "enabled": True,
+ "captureContent": False,
+ "otel": {
+ "enabled": True,
+ "endpoint": "https://langfuse.pre.example.com/api/public/otel",
+ "protocol": "http/protobuf",
+ },
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["diagnostics"]["enabled"] is True
+ assert "captureContent" not in cfg["diagnostics"]
+ assert cfg["diagnostics"]["otel"]["captureContent"] is False
+
+
+def test_bootstrap_enables_diagnostics_otel_for_langfuse_env():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["LANGFUSE_PUBLIC_KEY"] = "pk-test"
+ env["LANGFUSE_SECRET_KEY"] = "sk-test"
+ env["LANGFUSE_BASE_URL"] = "https://langfuse.pre.example.com/"
+ env["OTEL_SERVICE_NAME"] = "openclaw-langfuse-e2e"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ expected_auth = base64.b64encode(b"pk-test:sk-test").decode("ascii")
+ assert cfg["plugins"]["entries"]["diagnostics-otel"]["enabled"] is True
+ assert "diagnostics-otel" in cfg["plugins"]["allow"]
+ assert cfg["diagnostics"]["enabled"] is True
+ assert cfg["diagnostics"]["otel"] == {
+ "enabled": True,
+ "endpoint": "https://langfuse.pre.example.com/api/public/otel",
+ "protocol": "http/protobuf",
+ "serviceName": "openclaw-langfuse-e2e",
+ "traces": True,
+ "metrics": False,
+ "logs": False,
+ "headers": {
+ "Authorization": f"Basic {expected_auth}",
+ "x-langfuse-ingestion-version": "4",
+ },
+ }
+
+
+def test_bootstrap_maps_gateway_token_to_shared_secret_when_token_mode_enabled():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_GATEWAY_AUTH_MODE"] = "token"
+ env["OPENCLAW_GATEWAY_TOKEN"] = "gateway-token-demo"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["gateway"]["auth"]["mode"] == "token"
+ assert cfg["gateway"]["auth"]["password"] == "gateway-token-demo"
+
+
+def test_bootstrap_enables_openresponses_http_endpoint_by_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["gateway"]["http"]["endpoints"]["responses"]["enabled"] is True
+
+
+def test_bootstrap_respects_explicit_openresponses_http_endpoint_disable():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "gateway": {
+ "http": {
+ "endpoints": {
+ "responses": {
+ "enabled": False,
+ }
+ }
+ }
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["gateway"]["http"]["endpoints"]["responses"]["enabled"] is False
+
+
+def test_bootstrap_clears_stale_gateway_shared_secret_when_mode_returns_to_trusted_proxy():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "gateway": {
+ "auth": {
+ "mode": "token",
+ "password": "stale-secret",
+ }
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_GATEWAY_AUTH_MODE"] = "trusted-proxy"
+ env["OPENCLAW_GATEWAY_TOKEN"] = "stale-secret"
+ env["OPENCLAW_GATEWAY_PASSWORD"] = "stale-secret"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["gateway"]["auth"]["mode"] == "trusted-proxy"
+ assert "password" not in (cfg.get("gateway", {}).get("auth", {}))
+
+
+def test_bootstrap_keeps_env_secretref_when_explicitly_requested():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] = "env"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["models"]["providers"]["ksyun"]["apiKey"] == {
+ "source": "env",
+ "provider": "default",
+ "id": "OPENCLAW_MODEL_API_KEY",
+ }
+ assert cfg["secrets"]["providers"]["default"]["source"] == "env"
+ assert cfg["secrets"]["defaults"]["env"] == "default"
+
+
+def test_bootstrap_keeps_model_env_fallbacks_for_background_runs():
+ source = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8")
+
+ assert "false missing-auth failures against auth-profiles.json" in source
+ assert "unset OPENCLAW_MODEL_API_KEY OPENAI_API_KEY LLM_API_KEY MODEL_API_KEY" not in source
+
+
+def test_bootstrap_defaults_heartbeat_to_isolated_light_context():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["heartbeat"]["every"] == "30m"
+ assert cfg["agents"]["defaults"]["heartbeat"]["target"] == "none"
+ assert cfg["agents"]["defaults"]["heartbeat"]["isolatedSession"] is True
+ assert cfg["agents"]["defaults"]["heartbeat"]["lightContext"] is True
+
+
+def test_bootstrap_disables_exec_notify_on_exit_by_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["exec"]["notifyOnExit"] is False
+ assert cfg["tools"]["exec"]["notifyOnExitEmptySuccess"] is False
+
+
+def test_bootstrap_fails_without_secret_env_value():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ combined = f"{result.stdout}\n{result.stderr}"
+ assert "missing bootstrap secret env for file-backed model api key" in combined
+
+
+def test_bootstrap_does_not_keep_gateway_password_when_not_in_token_mode():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["gateway"]["auth"]["mode"] == "trusted-proxy"
+ assert "password" not in cfg["gateway"]["auth"]
+
+
+def test_bootstrap_defaults_dual_ksyun_catalog_when_unspecified():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6"
+ models = cfg["models"]["providers"]["ksyun"]["models"]
+ assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"]
+ assert models[0]["input"] == ["text"]
+ assert models[1]["input"] == ["text", "image"]
+ _assert_model_token_defaults(models)
+ selectable = cfg["agents"]["defaults"]["models"]
+ assert "ksyun/glm-5.1" in selectable
+ assert "ksyun/kimi-k2.6" in selectable
+
+
+def test_bootstrap_global_model_preference_keeps_dual_ksyun_catalog():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENAI_MODEL_NAME"] = "glm-5.1"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6"
+ models = cfg["models"]["providers"]["ksyun"]["models"]
+ assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"]
+ assert models[0]["input"] == ["text"]
+ assert models[1]["input"] == ["text", "image"]
+ _assert_model_token_defaults(models)
+
+
+def test_bootstrap_openclaw_default_model_alias_keeps_dual_catalog():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1"
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/kimi-k2.6"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6"
+ models = cfg["models"]["providers"]["ksyun"]["models"]
+ assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6"]
+ assert models[0]["input"] == ["text"]
+ assert models[1]["input"] == ["text", "image"]
+ _assert_model_token_defaults(models)
+ selectable = cfg["agents"]["defaults"]["models"]
+ assert "ksyun/glm-5.1" in selectable
+ assert "ksyun/kimi-k2.6" in selectable
+
+
+def test_bootstrap_preserves_existing_defaults_model_fallbacks_and_image_model():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "ksyun/deepseek-v3",
+ "fallbacks": ["ksyun/glm-5.1"],
+ },
+ "imageModel": {
+ "primary": "ksyun/kimi-k2.6",
+ },
+ }
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1"
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/deepseek-v3"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["ksyun/glm-5.1"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "ksyun/kimi-k2.6"
+
+
+def test_bootstrap_prefers_glm51_as_default_primary_when_catalog_is_present():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env.pop("OPENAI_MODEL_NAME", None)
+ env["OPENCLAW_MODEL_CATALOG_JSON"] = (
+ '[{"id":"kimi-k2.6"},{"id":"glm-5.1"}]'
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/glm-5.1"
+ models = cfg["models"]["providers"]["ksyun"]["models"]
+ assert [item["id"] for item in models] == ["kimi-k2.6", "glm-5.1"]
+
+
+def test_bootstrap_appends_primary_model_when_default_catalog_does_not_include_it():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENAI_MODEL_NAME"] = "ksyun/deepseek-v3"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "ksyun/deepseek-v3"
+ models = cfg["models"]["providers"]["ksyun"]["models"]
+ assert [item["id"] for item in models] == ["glm-5.1", "kimi-k2.6", "deepseek-v3"]
+ _assert_model_token_defaults(models)
+
+
+def test_bootstrap_qualifies_namespaced_model_selection_refs_when_provider_differs_from_prefix():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai"
+ env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env["OPENCLAW_MODEL_CATALOG_JSON"] = json.dumps(
+ [
+ {
+ "id": "Qzhou/glm-5",
+ "name": "glm-5",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text"],
+ },
+ {
+ "id": "Qzhou/kimi-k2.6",
+ "name": "kimi-k2.6",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text", "image"],
+ },
+ ]
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["hanhai/Qzhou/kimi-k2.6"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "hanhai/Qzhou/kimi-k2.6"
+ models = cfg["models"]["providers"]["hanhai"]["models"]
+ assert [item["id"] for item in models] == ["Qzhou/glm-5", "Qzhou/kimi-k2.6"]
+ selectable = cfg["agents"]["defaults"]["models"]
+ assert sorted(selectable) == ["hanhai/Qzhou/glm-5", "hanhai/Qzhou/kimi-k2.6"]
+
+
+def test_bootstrap_qualifies_namespaced_model_selection_refs_without_catalog_when_provider_differs():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai"
+ env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env.pop("OPENCLAW_MODEL_CATALOG_JSON", None)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5"
+ assert "fallbacks" not in cfg["agents"]["defaults"]["model"]
+ models = cfg["models"]["providers"]["hanhai"]["models"]
+ assert [item["id"] for item in models] == ["Qzhou/glm-5"]
+ selectable = cfg["agents"]["defaults"]["models"]
+ assert sorted(selectable) == ["hanhai/Qzhou/glm-5"]
+
+
+def test_bootstrap_migrates_legacy_namespaced_model_selection_refs_for_custom_provider():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "Qzhou/glm-5",
+ "fallbacks": ["Qzhou/kimi-k2.6"],
+ },
+ "imageModel": {
+ "primary": "Qzhou/kimi-k2.6",
+ },
+ "models": {
+ "Qzhou/glm-5": {},
+ "Qzhou/kimi-k2.6": {},
+ },
+ }
+ },
+ "models": {
+ "providers": {
+ "hanhai": {
+ "models": [
+ {
+ "id": "Qzhou/glm-5",
+ "name": "glm-5",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text"],
+ },
+ {
+ "id": "Qzhou/kimi-k2.6",
+ "name": "kimi-k2.6",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text", "image"],
+ },
+ ]
+ }
+ }
+ },
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_PROVIDER_ID"] = "hanhai"
+ env["OPENAI_MODEL_NAME"] = "Qzhou/glm-5"
+ env.pop("OPENCLAW_DEFAULT_MODEL", None)
+ env["OPENCLAW_MODEL_CATALOG_JSON"] = json.dumps(
+ [
+ {
+ "id": "Qzhou/glm-5",
+ "name": "glm-5",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text"],
+ },
+ {
+ "id": "Qzhou/kimi-k2.6",
+ "name": "kimi-k2.6",
+ "api": "openai-completions",
+ "reasoning": False,
+ "input": ["text", "image"],
+ },
+ ]
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["model"]["primary"] == "hanhai/Qzhou/glm-5"
+ assert cfg["agents"]["defaults"]["model"]["fallbacks"] == ["hanhai/Qzhou/kimi-k2.6"]
+ assert cfg["agents"]["defaults"]["imageModel"]["primary"] == "hanhai/Qzhou/kimi-k2.6"
+ assert sorted(cfg["agents"]["defaults"]["models"]) == [
+ "hanhai/Qzhou/glm-5",
+ "hanhai/Qzhou/kimi-k2.6",
+ ]
+ models = cfg["models"]["providers"]["hanhai"]["models"]
+ assert [item["id"] for item in models] == ["Qzhou/glm-5", "Qzhou/kimi-k2.6"]
+
+
+def test_bootstrap_disables_builtin_web_search_by_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["web"]["search"]["enabled"] is False
+ assert cfg["tools"]["web"]["fetch"]["enabled"] is False
+ assert "provider" not in cfg["tools"]["web"]["search"]
+ assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {})
+
+
+def test_bootstrap_cleans_up_legacy_auto_builtin_web_search():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "tools": {
+ "web": {
+ "search": {
+ "enabled": True,
+ "provider": "perplexity",
+ }
+ }
+ },
+ "plugins": {
+ "entries": {
+ "perplexity": {
+ "config": {
+ "webSearch": {
+ "baseUrl": "http://example.test/v1",
+ "model": "deepseek-v3.2",
+ "apiKey": {
+ "source": "file",
+ "provider": "default",
+ "id": "/providers/ksyun/apiKey",
+ },
+ }
+ }
+ }
+ }
+ },
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["web"]["search"]["enabled"] is False
+ assert "provider" not in cfg["tools"]["web"]["search"]
+ assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {})
+
+
+def test_bootstrap_preserves_explicit_builtin_web_search_provider():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "tools": {
+ "web": {
+ "search": {
+ "enabled": True,
+ "provider": "brave",
+ }
+ }
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["web"]["search"]["enabled"] is True
+ assert cfg["tools"]["web"]["search"]["provider"] == "brave"
+ assert "plugins" not in cfg or "perplexity" not in cfg.get("plugins", {}).get("entries", {})
+
+
+def test_bootstrap_disables_legacy_builtin_web_fetch_for_default_ksyun_runtime():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "tools": {
+ "web": {
+ "fetch": {
+ "enabled": True,
+ }
+ }
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_BASE_URL"] = "https://kspmas.ksyun.com/v1/"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["web"]["fetch"]["enabled"] is False
+
+
+def test_bootstrap_preserves_explicit_builtin_web_fetch_enablement_via_env():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_WEB_FETCH_ENABLED"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["web"]["fetch"]["enabled"] is True
+
+
+def test_bootstrap_enables_builtin_browser_by_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["browser"]["enabled"] is True
+ assert cfg["browser"]["headless"] is True
+ assert cfg["browser"]["noSandbox"] is True
+ assert cfg["browser"]["ssrfPolicy"] == {"dangerouslyAllowPrivateNetwork": True}
+
+
+def test_bootstrap_preserves_explicit_builtin_browser_enablement_in_config():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "browser": {
+ "enabled": True,
+ "headless": False,
+ "noSandbox": False,
+ "ssrfPolicy": {
+ "dangerouslyAllowPrivateNetwork": False,
+ "hostnameAllowlist": ["docs.example.com"],
+ },
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["browser"]["enabled"] is True
+ assert cfg["browser"]["headless"] is False
+ assert cfg["browser"]["noSandbox"] is False
+ assert cfg["browser"]["ssrfPolicy"] == {
+ "dangerouslyAllowPrivateNetwork": False,
+ "hostnameAllowlist": ["docs.example.com"],
+ }
+
+
+def test_bootstrap_allows_reenabling_builtin_browser_via_env():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_BROWSER_ENABLED"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["browser"]["enabled"] is True
+
+
+def test_bootstrap_allows_disabling_builtin_browser_via_env():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_BROWSER_ENABLED"] = "false"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["browser"]["enabled"] is False
+
+
+def test_bootstrap_keeps_browser_ssrf_policy_strict_in_strict_mode():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_EXEC_STRICT_MODE"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert "ssrfPolicy" not in cfg["browser"]
+
+
+def test_bootstrap_recovers_from_blank_secret_ref_env_overrides():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] = " file "
+ env["OPENCLAW_MODEL_API_KEY_SECRET_PROVIDER"] = " default "
+ env["OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH"] = " "
+ env["OPENCLAW_MODEL_API_KEY_SECRET_ID"] = " "
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["secrets"]["providers"]["default"] == {
+ "source": "file",
+ "path": str(Path(tmpdir) / "secrets.json"),
+ "mode": "json",
+ }
+ assert cfg["models"]["providers"]["ksyun"]["apiKey"] == {
+ "source": "file",
+ "provider": "default",
+ "id": "/providers/ksyun/apiKey",
+ }
+
+
+def test_bootstrap_syncs_kdocs_by_default_without_token():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "tavily-search",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ synced_skills = sorted(
+ path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir()
+ )
+ assert synced_skills == [
+ "agent-browser-clawdbot",
+ "clawhub-store",
+ "kdocs",
+ ]
+
+
+def test_bootstrap_removes_previously_synced_removed_preset_skill_when_unchanged():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ skills_dir = Path(tmpdir) / "skills"
+ managed_find_skills_dir = skills_dir / "find-skills"
+ managed_find_skills_dir.mkdir(parents=True, exist_ok=True)
+ (managed_find_skills_dir / "SKILL.md").write_text("legacy managed find-skills\n")
+
+ cache_dir = Path(tmpdir) / ".bootstrap-cache" / "preset-skills"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ (cache_dir / "find-skills.sig").write_text(
+ _compute_directory_signature(managed_find_skills_dir) + "\n"
+ )
+
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not managed_find_skills_dir.exists()
+ assert not (cache_dir / "find-skills.sig").exists()
+
+
+def test_bootstrap_preserves_user_managed_removed_preset_skill():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ user_find_skills_dir = Path(tmpdir) / "skills" / "find-skills"
+ user_find_skills_dir.mkdir(parents=True, exist_ok=True)
+ (user_find_skills_dir / "SKILL.md").write_text("custom user find-skills\n")
+ (user_find_skills_dir / "README.md").write_text("owned-by-user\n")
+
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert (user_find_skills_dir / "SKILL.md").read_text() == "custom user find-skills\n"
+ assert (user_find_skills_dir / "README.md").read_text() == "owned-by-user\n"
+ assert "preserved user-managed skill find-skills" in result.stderr
+
+
+def test_bootstrap_removes_previously_synced_multi_search_skill_when_no_longer_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ skills_dir = Path(tmpdir) / "skills"
+ managed_multi_search_dir = skills_dir / "multi-search-engine"
+ managed_multi_search_dir.mkdir(parents=True, exist_ok=True)
+ (managed_multi_search_dir / "SKILL.md").write_text("legacy managed multi-search-engine\n")
+
+ cache_dir = Path(tmpdir) / ".bootstrap-cache" / "preset-skills"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ (cache_dir / "multi-search-engine.sig").write_text(
+ _compute_directory_signature(managed_multi_search_dir) + "\n"
+ )
+
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not managed_multi_search_dir.exists()
+ assert not (cache_dir / "multi-search-engine.sig").exists()
+
+
+def test_bootstrap_removes_legacy_multi_search_skill_by_source_match_without_sig():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ skills_dir = Path(tmpdir) / "skills"
+ bundled_multi_search_dir = preset_skills_dir / "multi-search-engine"
+ managed_multi_search_dir = skills_dir / "multi-search-engine"
+
+ bundled_multi_search_dir.mkdir(parents=True, exist_ok=True)
+ (bundled_multi_search_dir / "SKILL.md").write_text("legacy bundled multi-search-engine\n")
+ managed_multi_search_dir.mkdir(parents=True, exist_ok=True)
+ (managed_multi_search_dir / "SKILL.md").write_text("legacy bundled multi-search-engine\n")
+
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not managed_multi_search_dir.exists()
+ assert "removed deprecated bundled skill multi-search-engine (legacy source match)" in result.stderr
+
+
+def test_bootstrap_syncs_multi_search_skill_when_explicitly_allowlisted():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "multi-search-engine",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+ env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = (
+ "clawhub-store,agent-browser-clawdbot,kdocs,multi-search-engine"
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ synced_skills = sorted(
+ path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir()
+ )
+ assert synced_skills == [
+ "agent-browser-clawdbot",
+ "clawhub-store",
+ "kdocs",
+ "multi-search-engine",
+ ]
+
+
+def test_bootstrap_enforces_exec_approval_defaults():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ approvals_path = Path(tmpdir) / "exec-approvals.json"
+ workspace_path = Path(tmpdir) / "workspace"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["fs"]["workspaceOnly"] is False
+ assert cfg["tools"]["exec"]["host"] == "gateway"
+ assert cfg["tools"]["exec"]["security"] == "full"
+ assert cfg["tools"]["exec"]["ask"] == "off"
+ assert "pathPrepend" not in cfg["tools"]["exec"]
+ assert cfg["tools"]["elevated"]["enabled"] is False
+ assert cfg["agents"]["defaults"]["workspace"] == str(workspace_path)
+
+ approvals = json.loads(approvals_path.read_text())
+ assert approvals["defaults"] == {
+ "security": "full",
+ "ask": "off",
+ "askFallback": "full",
+ "autoAllowSkills": False,
+ }
+ assert "agents" not in approvals or "main" not in approvals.get("agents", {})
+ assert not (workspace_path / "SOUL.md").exists()
+ assert not (workspace_path / "AGENTS.md").exists()
+ assert not (workspace_path / "MEMORY.md").exists()
+ assert not (workspace_path / "USER.MD").exists()
+ assert not (workspace_path / "TOOLS.md").exists()
+
+
+def test_bootstrap_strict_mode_keeps_security_templates():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ workspace_path = Path(tmpdir) / "workspace"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_EXEC_STRICT_MODE"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert (workspace_path / "SOUL.md").exists()
+ assert (workspace_path / "AGENTS.md").exists()
+ assert (workspace_path / "MEMORY.md").exists()
+ assert (workspace_path / "USER.MD").exists()
+ assert (workspace_path / "TOOLS.md").exists()
+
+
+def test_bootstrap_relaxed_mode_cleans_legacy_builtin_security_templates():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ workspace_path = Path(tmpdir) / "workspace"
+ workspace_path.mkdir(parents=True, exist_ok=True)
+ (workspace_path / "SOUL.md").write_text("security soul\n")
+ (workspace_path / "AGENTS.md").write_text("security agents\n")
+ (workspace_path / "MEMORY.md").write_text("persistent memory\n")
+ (workspace_path / "USER.MD").write_text("user preferences\n")
+ (workspace_path / "TOOLS.md").write_text("tool notes\n")
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not (workspace_path / "SOUL.md").exists()
+ assert not (workspace_path / "AGENTS.md").exists()
+ assert not (workspace_path / "MEMORY.md").exists()
+ assert not (workspace_path / "USER.MD").exists()
+ assert not (workspace_path / "TOOLS.md").exists()
+
+
+def test_bootstrap_relaxed_mode_preserves_customized_security_templates():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ workspace_path = Path(tmpdir) / "workspace"
+ workspace_path.mkdir(parents=True, exist_ok=True)
+ soul_path = workspace_path / "SOUL.md"
+ agents_path = workspace_path / "AGENTS.md"
+ memory_path = workspace_path / "MEMORY.md"
+ user_path = workspace_path / "USER.MD"
+ tools_path = workspace_path / "TOOLS.md"
+ soul_path.write_text("my custom soul\n")
+ agents_path.write_text("my custom agents\n")
+ memory_path.write_text("my custom memory\n")
+ user_path.write_text("my custom user prefs\n")
+ tools_path.write_text("my custom tools\n")
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert soul_path.read_text() == "my custom soul\n"
+ assert agents_path.read_text() == "my custom agents\n"
+ assert memory_path.read_text() == "my custom memory\n"
+ assert user_path.read_text() == "my custom user prefs\n"
+ assert tools_path.read_text() == "my custom tools\n"
+
+
+def test_bootstrap_preserves_existing_memory_file():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ workspace_path = Path(tmpdir) / "workspace"
+ workspace_path.mkdir(parents=True, exist_ok=True)
+ memory_path = workspace_path / "MEMORY.md"
+ memory_path.write_text("user customized memory\n")
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert memory_path.read_text() == "user customized memory\n"
+
+
+def test_bootstrap_strict_mode_restores_allowlist_defaults():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ approvals_path = Path(tmpdir) / "exec-approvals.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_EXEC_STRICT_MODE"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["tools"]["exec"]["security"] == "allowlist"
+ approvals = json.loads(approvals_path.read_text())
+ assert approvals["defaults"]["security"] == "allowlist"
+ allowlist = approvals["agents"]["main"]["allowlist"]
+ patterns = {entry["pattern"] for entry in allowlist}
+ command_names = {Path(pattern).name for pattern in patterns}
+ assert str(Path(tmpdir) / "safe-bin" / "bash-safe") in patterns
+ assert "curl" in command_names
+ assert "jq" in command_names
+ assert "openclaw" in command_names
+ assert "agent-browser" in command_names
+ assert "yt-dlp" not in command_names
+ assert "gh" not in command_names
+ assert "xreach" not in command_names
+
+
+def test_multi_search_skill_avoids_curl_head_broken_pipe_pattern():
+ skill_path = (
+ REPO_ROOT
+ / "deploy"
+ / "openclaw"
+ / "preset-skills"
+ / "multi-search-engine"
+ / "SKILL.md"
+ )
+
+ content = skill_path.read_text()
+
+ assert "curl -sS \"https://www.baidu.com/s?wd=QUERY\" | head -200" not in content
+
+
+def test_multi_search_skill_prefers_cn_bing_and_builtin_browser_first():
+ skill_path = (
+ REPO_ROOT
+ / "deploy"
+ / "openclaw"
+ / "preset-skills"
+ / "multi-search-engine"
+ / "SKILL.md"
+ )
+
+ content = skill_path.read_text()
+
+ assert "built-in fetch tool" not in content
+ assert "start with Bing CN / Sogou / 360 before trying Baidu" in content
+ assert "prefer the built-in `browser` tool first in this runtime" in content
+ assert "agent-browser open \"https://cn.bing.com/search?q=QUERY&ensearch=0\"" in content
+ assert "browser navigate https://www.baidu.com/s?wd=QUERY" not in content
+ assert "curl -sS \"https://cn.bing.com/search?q=QUERY\" | head -200" not in content
+ assert "Failure writing output to destination" in content
+
+
+def test_bootstrap_merges_custom_exec_allowlist_patterns():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ approvals_path = Path(tmpdir) / "exec-approvals.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_EXEC_ALLOWLIST"] = "/opt/tools/read-only,/custom/bin/inspect"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ approvals = json.loads(approvals_path.read_text())
+ patterns = {entry["pattern"] for entry in approvals["agents"]["main"]["allowlist"]}
+ assert "/opt/tools/read-only" in patterns
+ assert "/custom/bin/inspect" in patterns
+
+
+def test_bootstrap_keeps_model_api_key_in_gateway_process_env_for_deferred_auth_paths():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ captured_env_path = Path(tmpdir) / "gateway.env"
+ fake_bin_dir = Path(tmpdir) / "bin"
+ fake_node_path = fake_bin_dir / "node"
+ real_node_path = subprocess.run(
+ ["bash", "-lc", "command -v node"],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout.strip()
+ fake_bin_dir.mkdir()
+ fake_node_path.write_text(
+ "#!/bin/sh\n"
+ 'if [ "$1" = "openclaw.mjs" ] && [ "$2" = "gateway" ] && [ "$3" = "run" ]; then\n'
+ ' printenv | sort > "${BOOTSTRAP_CAPTURE_ENV_PATH}"\n'
+ " exit 0\n"
+ "fi\n"
+ f'exec "{real_node_path}" "$@"\n'
+ )
+ fake_node_path.chmod(0o755)
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["BOOTSTRAP_CAPTURE_ENV_PATH"] = str(captured_env_path)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+ env["OPENCLAW_RUNTIME_PROXY_ENABLED"] = "0"
+ env["PATH"] = f"{fake_bin_dir}:{env['PATH']}"
+ env.pop("OPENCLAW_BOOTSTRAP_ONLY", None)
+
+ process = subprocess.Popen(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ deadline = time.monotonic() + 5
+ while not captured_env_path.exists() and time.monotonic() < deadline:
+ if process.poll() is not None:
+ break
+ time.sleep(0.05)
+
+ assert captured_env_path.exists(), process.stderr.read() or process.stdout.read()
+
+ process.terminate()
+ process.communicate(timeout=5)
+
+ captured_env = captured_env_path.read_text()
+ assert "OPENCLAW_MODEL_API_KEY=dummy-secret-value" in captured_env
+ assert "OPENAI_API_KEY=" not in captured_env
+ assert "OPENCLAW_INTERNAL_TRUSTED_PROXY_USER=openclaw-backend" in captured_env
+ assert "OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER=x-forwarded-user" in captured_env
+ assert "CLAWHUB_SITE=https://cn.clawhub-mirror.com" in captured_env
+ assert "CLAWHUB_REGISTRY=https://cn.clawhub-mirror.com" in captured_env
+ assert "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com" in captured_env
+ assert "PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in captured_env
+ assert "PIP_TRUSTED_HOST=mirrors.aliyun.com" in captured_env
+ assert "UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in captured_env
+ assert "PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright" in captured_env
+ assert "PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing" in captured_env
+
+
+def test_bootstrap_does_not_relaunch_gateway_after_upstream_handoff_restart():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ gateway_count_path = Path(tmpdir) / "gateway-count.txt"
+ successor_pid_path = Path(tmpdir) / "gateway-successor.pid"
+ fake_bin_dir = Path(tmpdir) / "bin"
+ fake_node_path = fake_bin_dir / "node"
+ real_node_path = subprocess.run(
+ ["bash", "-lc", "command -v node"],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout.strip()
+ fake_bin_dir.mkdir()
+ fake_node_path.write_text(
+ "#!/bin/sh\n"
+ 'if [ "$1" = "openclaw.mjs" ] && [ "$2" = "gateway" ] && [ "$3" = "run" ]; then\n'
+ ' count=0\n'
+ ' if [ -f "${BOOTSTRAP_GATEWAY_COUNT_PATH}" ]; then\n'
+ ' count="$(cat "${BOOTSTRAP_GATEWAY_COUNT_PATH}")"\n'
+ " fi\n"
+ ' count=$((count + 1))\n'
+ ' printf "%s\\n" "${count}" > "${BOOTSTRAP_GATEWAY_COUNT_PATH}"\n'
+ ' if [ "${count}" -eq 1 ]; then\n'
+ ' python3 -m http.server "${OPENCLAW_GATEWAY_PORT}" --bind 127.0.0.1 >/dev/null 2>&1 &\n'
+ ' printf "%s\\n" "$!" > "${BOOTSTRAP_SUCCESSOR_PID_PATH}"\n'
+ " exit 0\n"
+ " fi\n"
+ " exit 97\n"
+ "fi\n"
+ f'exec "{real_node_path}" "$@"\n'
+ )
+ fake_node_path.chmod(0o755)
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["BOOTSTRAP_GATEWAY_COUNT_PATH"] = str(gateway_count_path)
+ env["BOOTSTRAP_SUCCESSOR_PID_PATH"] = str(successor_pid_path)
+ env["OPENCLAW_GATEWAY_PORT"] = "18080"
+ env["OPENCLAW_GATEWAY_LOCAL_RESTART_MAX"] = "0"
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+ env["PATH"] = f"{fake_bin_dir}:{env['PATH']}"
+ env.pop("OPENCLAW_BOOTSTRAP_ONLY", None)
+
+ process = subprocess.Popen(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ try:
+ deadline = time.monotonic() + 5
+ while (
+ not gateway_count_path.exists() or not successor_pid_path.exists()
+ ) and time.monotonic() < deadline:
+ if process.poll() is not None:
+ break
+ time.sleep(0.05)
+
+ assert gateway_count_path.exists(), process.stderr.read() or process.stdout.read()
+ assert successor_pid_path.exists(), process.stderr.read() or process.stdout.read()
+
+ time.sleep(1.0)
+ assert process.poll() is None, process.stderr.read() or process.stdout.read()
+ assert gateway_count_path.read_text().strip() == "1"
+ finally:
+ if successor_pid_path.exists():
+ successor_pid = successor_pid_path.read_text().strip()
+ if successor_pid:
+ subprocess.run(
+ ["kill", successor_pid],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ process.terminate()
+ try:
+ process.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.communicate(timeout=5)
+
+
+def test_bootstrap_runtime_proxy_moves_gateway_to_internal_port():
+ bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8")
+
+ assert 'RUNTIME_PROXY_ENABLED="${OPENCLAW_RUNTIME_PROXY_ENABLED:-true}"' in bootstrap
+ assert 'GATEWAY_INTERNAL_PORT="${OPENCLAW_GATEWAY_INTERNAL_PORT:-18080}"' in bootstrap
+ assert 'GATEWAY_LISTENER_PORT="${GATEWAY_INTERNAL_PORT:-18080}"' in bootstrap
+ assert 'OPENCLAW_GATEWAY_PROXY_BASE_URL="http://127.0.0.1:${GATEWAY_LISTENER_PORT}"' in bootstrap
+ assert "uvicorn openclaw_runtime_proxy_app:app" in bootstrap
+ assert '--port "${GATEWAY_PORT}"' in bootstrap
+ assert 'node openclaw.mjs gateway run --allow-unconfigured --bind "${BIND_MODE}" --port "${GATEWAY_LISTENER_PORT}"' in bootstrap
+
+
+def test_bootstrap_writes_domestic_runtime_defaults_to_env_file():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ runtime_env = (Path(tmpdir) / ".env").read_text()
+ assert "CLAWHUB_SITE=https://cn.clawhub-mirror.com" in runtime_env
+ assert "CLAWHUB_REGISTRY=https://cn.clawhub-mirror.com" in runtime_env
+ assert "NPM_CONFIG_REGISTRY=https://registry.npmmirror.com" in runtime_env
+ assert "npm_config_registry=https://registry.npmmirror.com" in runtime_env
+ assert "YARN_NPM_REGISTRY_SERVER=https://registry.npmmirror.com" in runtime_env
+ assert "PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in runtime_env
+ assert "PIP_TRUSTED_HOST=mirrors.aliyun.com" in runtime_env
+ assert "UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple" in runtime_env
+ assert "PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright" in runtime_env
+ assert "PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing" in runtime_env
+
+
+def test_agent_browser_skill_prefers_domestic_examples():
+ skill_path = (
+ REPO_ROOT
+ / "deploy"
+ / "openclaw"
+ / "preset-skills"
+ / "agent-browser-clawdbot"
+ / "SKILL.md"
+ )
+
+ content = skill_path.read_text()
+
+ assert "agent-browser open https://www.google.com" not in content
+ assert "agent-browser open https://www.baidu.com" not in content
+ assert "agent-browser open https://cn.bing.com/search?q=AI+agents&ensearch=0" in content
+ assert "https://www.bing.com/news/search?q=AI&mkt=zh-CN" in content
+ assert "This image already bundles `agent-browser`" in content
+ assert "NPM_CONFIG_REGISTRY" in content
+ assert "PLAYWRIGHT_DOWNLOAD_HOST" in content
+ assert "Built-in `browser` is enabled by default in this image." in content
+ assert "Use `web-safe search` / `web-safe read` only when a cheap read-only fallback is enough" in content
+ assert "The task is small enough that a one-off interactive browser session is simpler" not in content
+
+
+def test_bootstrap_does_not_auto_register_exa_defaults():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ raw_bin_dir = Path(tmpdir) / "bin"
+ capture_path = Path(tmpdir) / "mcporter.log"
+ raw_bin_dir.mkdir(parents=True, exist_ok=True)
+ mcporter_path = raw_bin_dir / "mcporter"
+ mcporter_path.write_text(
+ "#!/bin/sh\n"
+ "if [ \"$1\" = \"config\" ] && [ \"$2\" = \"get\" ]; then\n"
+ " exit 1\n"
+ "fi\n"
+ "printf '%s\\n' \"$*\" >> \"$MCPORTER_CAPTURE_PATH\"\n"
+ "exit 0\n"
+ )
+ mcporter_path.chmod(0o755)
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["MCPORTER_CAPTURE_PATH"] = str(capture_path)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not capture_path.exists()
+
+
+def test_bootstrap_seeds_and_auto_enables_bundled_weixin_plugin():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-weixin"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert (Path(tmpdir) / "extensions" / "openclaw-weixin" / "manifest.json").exists()
+ assert cfg["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True
+
+
+def test_bootstrap_does_not_seed_deferred_mem0_plugin_by_default():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-mem0"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-mem0"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert not (Path(tmpdir) / "extensions" / "openclaw-mem0").exists()
+ cfg = json.loads(config_path.read_text())
+ assert "openclaw-mem0" not in (cfg.get("plugins", {}).get("entries", {}) or {})
+
+
+def test_bootstrap_preserves_user_managed_weixin_plugin_in_existing_extension_dir():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"2.0.0"}\n'
+ )
+ (default_extensions_dir / "README.md").write_text("bundled-v2\n")
+
+ existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin"
+ existing_extension_dir.mkdir(parents=True, exist_ok=True)
+ (existing_extension_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"1.0.0"}\n'
+ )
+ (existing_extension_dir / "stale.txt").write_text("old-plugin-layout\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert (existing_extension_dir / "manifest.json").read_text() == (
+ '{"name":"openclaw-weixin","version":"1.0.0"}\n'
+ )
+ assert not (existing_extension_dir / "README.md").exists()
+ assert (existing_extension_dir / "stale.txt").read_text() == "old-plugin-layout\n"
+
+
+def test_bootstrap_upgrades_previously_synced_weixin_plugin_when_bundle_changes():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ (default_extensions_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"1.0.0"}\n'
+ )
+ (default_extensions_dir / "README.md").write_text("bundled-v1\n")
+
+ first = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert first.returncode == 0, first.stderr or first.stdout
+
+ (default_extensions_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"2.0.0"}\n'
+ )
+ (default_extensions_dir / "README.md").write_text("bundled-v2\n")
+
+ second = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert second.returncode == 0, second.stderr or second.stdout
+
+ existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin"
+ assert (existing_extension_dir / "manifest.json").read_text() == (
+ '{"name":"openclaw-weixin","version":"2.0.0"}\n'
+ )
+ assert (existing_extension_dir / "README.md").read_text() == "bundled-v2\n"
+
+
+def test_bootstrap_preserves_user_modified_weixin_plugin_after_initial_seed():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ (default_extensions_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"1.0.0"}\n'
+ )
+
+ first = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert first.returncode == 0, first.stderr or first.stdout
+
+ existing_extension_dir = Path(tmpdir) / "extensions" / "openclaw-weixin"
+ (existing_extension_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"9.9.9-user"}\n'
+ )
+ (existing_extension_dir / "USER.md").write_text("custom-user-plugin\n")
+ (default_extensions_dir / "manifest.json").write_text(
+ '{"name":"openclaw-weixin","version":"2.0.0"}\n'
+ )
+ (default_extensions_dir / "README.md").write_text("bundled-v2\n")
+
+ second = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert second.returncode == 0, second.stderr or second.stdout
+
+ assert (existing_extension_dir / "manifest.json").read_text() == (
+ '{"name":"openclaw-weixin","version":"9.9.9-user"}\n'
+ )
+ assert (existing_extension_dir / "USER.md").read_text() == "custom-user-plugin\n"
+ assert not (existing_extension_dir / "README.md").exists()
+
+
+def test_bootstrap_preserves_existing_weixin_plugin_disablement():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-weixin"}\n')
+ config_path.write_text(
+ json.dumps(
+ {
+ "plugins": {
+ "entries": {
+ "openclaw-weixin": {
+ "enabled": False,
+ }
+ }
+ }
+ }
+ )
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert (Path(tmpdir) / "extensions" / "openclaw-weixin" / "manifest.json").exists()
+ assert cfg["plugins"]["entries"]["openclaw-weixin"]["enabled"] is False
+
+
+def test_bootstrap_auto_enables_bundled_lark_plugin():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert (Path(tmpdir) / "extensions" / "openclaw-lark" / "manifest.json").exists()
+ assert cfg["plugins"]["entries"]["openclaw-lark"]["enabled"] is True
+
+
+def test_bootstrap_configures_wps_xiezuo_channel_from_channel_bootstrap_json():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps(
+ {
+ "wps-xiezuo": {
+ "appId": "app-demo",
+ "appSecret": "secret-demo",
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert (Path(tmpdir) / "extensions" / "wps-xiezuo" / "manifest.json").exists()
+ assert cfg["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True
+ assert "wps-xiezuo" in cfg["plugins"]["allow"]
+ channel = cfg["channels"]["wps-xiezuo"]
+ assert channel["enabled"] is True
+ assert channel["appId"] == "app-demo"
+ assert channel["appSecret"] == "secret-demo"
+ assert channel["baseUrl"] == "https://openapi.wps.cn"
+ assert channel["sdk"] == {"enabled": True, "logLevel": "info"}
+ assert channel["dmPolicy"] == "open"
+ assert channel["allowFrom"] == ["*"]
+ assert channel["groupPolicy"] == "open"
+ assert channel["instantAck"]["text"] == "内容处理中,请稍候..."
+ assert channel["mcp"]["enabled"] is True
+ assert channel["mcp"]["mode"] == "app"
+ assert "toolAllowlist" not in channel["mcp"]
+ assert "accounts" not in channel
+ assert "defaultAccountId" not in channel
+ assert {"type": "route", "agentId": "main", "match": {"channel": "wps-xiezuo"}} in cfg["bindings"]
+
+
+def test_bootstrap_allows_wps_xiezuo_channel_without_complete_credentials():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps(
+ {
+ "wps-xiezuo": {
+ "appId": "app-demo",
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ channel = cfg["channels"]["wps-xiezuo"]
+ assert channel["enabled"] is True
+ assert channel["appId"] == "app-demo"
+ assert channel["appSecret"] == ""
+ assert channel["baseUrl"] == "https://openapi.wps.cn"
+ assert channel["dmPolicy"] == "open"
+ assert channel["allowFrom"] == ["*"]
+ assert cfg["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True
+ assert "wps-xiezuo" in cfg["plugins"]["allow"]
+
+
+def test_bootstrap_preserves_explicit_wps_xiezuo_mcp_tool_allowlist():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps(
+ {
+ "wps-xiezuo": {
+ "appId": "app-demo",
+ "appSecret": "secret-demo",
+ "mcp": {
+ "enabled": True,
+ "mode": "app",
+ "toolAllowlist": ["wps_message_send"],
+ },
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["channels"]["wps-xiezuo"]["mcp"]["toolAllowlist"] == ["wps_message_send"]
+
+
+def test_bootstrap_rewrites_stale_wps_xiezuo_accounts_to_flat_channel_config():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "channels": {
+ "wps-xiezuo": {
+ "accounts": {
+ "default": {
+ "appId": "app-stale",
+ }
+ }
+ }
+ }
+ }
+ )
+ )
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "wps-xiezuo"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"wps-xiezuo"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps(
+ {
+ "wps-xiezuo": {
+ "appId": "app-demo",
+ "appSecret": "secret-demo",
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ channel = cfg["channels"]["wps-xiezuo"]
+ assert channel["appId"] == "app-demo"
+ assert channel["appSecret"] == "secret-demo"
+ assert "accounts" not in channel
+ assert "defaultAccountId" not in channel
+
+
+def test_bootstrap_configures_feishu_channel_from_channel_bootstrap_json():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] = json.dumps(
+ {
+ "feishu": {
+ "appId": "cli-app-id",
+ "appSecret": "cli-app-secret",
+ "domain": "lark",
+ }
+ }
+ )
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["plugins"]["entries"]["openclaw-lark"]["enabled"] is True
+ assert cfg["channels"]["feishu"]["enabled"] is True
+ assert cfg["channels"]["feishu"]["appId"] == "cli-app-id"
+ assert cfg["channels"]["feishu"]["appSecret"] == "cli-app-secret"
+ assert cfg["channels"]["feishu"]["domain"] == "lark"
+ assert cfg["channels"]["feishu"]["connectionMode"] == "websocket"
+ assert cfg["channels"]["feishu"]["requireMention"] is True
+ assert cfg["channels"]["feishu"]["dmPolicy"] == "pairing"
+ assert cfg["channels"]["feishu"]["groupPolicy"] == "open"
+
+
+def test_bootstrap_keeps_feishu_open_dm_policy_valid_when_existing_allow_from_is_specific():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "channels": {
+ "feishu": {
+ "enabled": True,
+ "appId": "cli-app-id",
+ "appSecret": "cli-app-secret",
+ "dmPolicy": "open",
+ "allowFrom": ["ou_demo_1", "ou_demo_2"],
+ }
+ }
+ }
+ )
+ )
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-lark"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-lark"}\n')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["channels"]["feishu"]["dmPolicy"] == "open"
+ assert cfg["channels"]["feishu"]["allowFrom"] == ["ou_demo_1", "ou_demo_2", "*"]
+
+
+def test_bootstrap_patches_bundled_weixin_gateway_login_methods_before_sync():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ plugin_dir = plugin_root / "src"
+ plugin_dir.mkdir(parents=True, exist_ok=True)
+ _write_weixin_plugin_package_json(plugin_root)
+ (plugin_dir / "channel.ts").write_text(
+ "export const weixinPlugin = {\n"
+ " status: {\n"
+ " defaultRuntime: {},\n"
+ " },\n"
+ "};\n"
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ bundled_source = (Path(tmpdir) / "default-extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text()
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in bundled_source
+ patched_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text()
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_source
+
+
+def test_bootstrap_patches_latest_weixin_gateway_login_methods_without_version_skip():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ plugin_src_dir = plugin_root / "src"
+
+ plugin_src_dir.mkdir(parents=True, exist_ok=True)
+ _write_weixin_plugin_package_json(plugin_root, version="2.1.7")
+ (plugin_src_dir / "channel.ts").write_text(
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ patched_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text()
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_source
+ assert "skipped bundled channel plugin compat patch" not in result.stderr
+
+
+def test_bootstrap_only_adds_gateway_login_methods_for_target_weixin_version():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ (plugin_root / "src").mkdir(parents=True, exist_ok=True)
+
+ _write_weixin_plugin_package_json(plugin_root)
+ (plugin_root / "index.ts").write_text(
+ 'import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";\n'
+ 'import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";\n'
+ )
+ (plugin_root / "src" / "channel.ts").write_text(
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ synced_root = Path(tmpdir) / "extensions" / "openclaw-weixin"
+ assert 'openclaw/plugin-sdk/plugin-entry' in (synced_root / "index.ts").read_text()
+ patched_channel = (synced_root / "src" / "channel.ts").read_text()
+ assert 'openclaw/plugin-sdk/infra-runtime' in patched_channel
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_channel
+ assert not (synced_root / "node_modules" / "openclaw").exists()
+
+
+def test_bootstrap_patches_weixin_remote_login_patch_for_newer_official_version():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ plugin_src_dir = plugin_root / "src"
+
+ plugin_src_dir.mkdir(parents=True, exist_ok=True)
+
+ _write_weixin_plugin_package_json(plugin_root, version="2.1.8")
+ (plugin_src_dir / "channel.ts").write_text(
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ bundled_source = (plugin_root / "src" / "channel.ts").read_text()
+ synced_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text()
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in bundled_source
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in synced_source
+ assert "skipped bundled channel plugin compat patch" not in result.stderr
+
+
+def test_bootstrap_skips_weixin_remote_login_patch_for_older_official_version():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ plugin_src_dir = plugin_root / "src"
+
+ plugin_src_dir.mkdir(parents=True, exist_ok=True)
+
+ _write_weixin_plugin_package_json(plugin_root, version="2.0.2")
+ (plugin_src_dir / "channel.ts").write_text(
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ bundled_source = (plugin_root / "src" / "channel.ts").read_text()
+ synced_source = (Path(tmpdir) / "extensions" / "openclaw-weixin" / "src" / "channel.ts").read_text()
+ assert 'openclaw/plugin-sdk/infra-runtime' in bundled_source
+ assert 'openclaw/plugin-sdk/infra-runtime' in synced_source
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' not in bundled_source
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' not in synced_source
+ assert "skipped bundled channel plugin compat patch" in result.stderr
+
+
+def test_bootstrap_does_not_runtime_patch_user_managed_weixin_plugin():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ bundled_plugin_root = Path(tmpdir) / "default-extensions" / "openclaw-weixin"
+ existing_plugin_root = Path(tmpdir) / "extensions" / "openclaw-weixin"
+ (bundled_plugin_root / "src").mkdir(parents=True, exist_ok=True)
+ (existing_plugin_root / "src").mkdir(parents=True, exist_ok=True)
+
+ _write_weixin_plugin_package_json(bundled_plugin_root, version="2.1.7")
+ _write_weixin_plugin_package_json(existing_plugin_root, version="9.9.9-user")
+ (bundled_plugin_root / "src" / "channel.ts").write_text(
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+ original_user_source = (
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {},\n'
+ '};\n'
+ )
+ (existing_plugin_root / "src" / "channel.ts").write_text(original_user_source)
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert (existing_plugin_root / "src" / "channel.ts").read_text() == original_user_source
+ assert "preserved user-managed extension openclaw-weixin" in result.stderr
+
+
+def test_bootstrap_runtime_patches_existing_official_weixin_216_plugin():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ existing_plugin_root = Path(tmpdir) / "extensions" / "openclaw-weixin"
+
+ (existing_plugin_root / "src").mkdir(parents=True, exist_ok=True)
+
+ _write_weixin_plugin_package_json(existing_plugin_root, version="2.1.7")
+ (existing_plugin_root / "index.ts").write_text(
+ 'import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";\n'
+ )
+ (existing_plugin_root / "src" / "channel.ts").write_text(
+ 'import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";\n'
+ 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";\n'
+ 'import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";\n'
+ 'export const weixinPlugin = {\n'
+ ' status: {\n'
+ ' defaultRuntime: {},\n'
+ ' },\n'
+ '};\n'
+ )
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert 'openclaw/plugin-sdk/plugin-entry' in (existing_plugin_root / "index.ts").read_text()
+ patched_channel = (existing_plugin_root / "src" / "channel.ts").read_text()
+ assert 'openclaw/plugin-sdk/infra-runtime' in patched_channel
+ assert 'gatewayMethods: ["web.login.start", "web.login.wait"],' in patched_channel
+ assert not (existing_plugin_root / "node_modules" / "openclaw").exists()
+
+
+def test_bootstrap_runs_bundled_kdocs_setup_when_token_present():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ marker_path = Path(tmpdir) / "kdocs.marker"
+ preset_skills_dir = Path(tmpdir) / "preset-skills" / "kdocs"
+ preset_skills_dir.mkdir(parents=True, exist_ok=True)
+ (preset_skills_dir / "setup.sh").write_text(
+ "#!/usr/bin/env bash\n"
+ "set -euo pipefail\n"
+ "printf '%s\\n' \"${KDOCS_TOKEN}\" > \"${OPENCLAW_KDOCS_MARKER_PATH}\"\n"
+ )
+ (preset_skills_dir / "setup.sh").chmod(0o755)
+ (preset_skills_dir / "SKILL.md").write_text("kdocs skill\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(Path(tmpdir) / "preset-skills")
+ env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = "kdocs"
+ env["OPENCLAW_KDOCS_MARKER_PATH"] = str(marker_path)
+ env["KDOCS_TOKEN"] = "kdocs-test-token"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert marker_path.read_text() == "kdocs-test-token\n"
+ assert (Path(tmpdir) / "skills" / "kdocs" / "setup.sh").exists()
+
+
+def test_bootstrap_syncs_only_allowlisted_preset_skills():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "self-improving-agent",
+ "kdocs",
+ "agent-reach",
+ "tavily-search",
+ "tuanziguardianclaw",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ synced_skills = sorted(path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir())
+ assert synced_skills == [
+ "agent-browser-clawdbot",
+ "clawhub-store",
+ "kdocs",
+ ]
+ cfg = json.loads(config_path.read_text())
+ assert cfg["skills"]["allowBundled"] == [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "wps365-skill",
+ ]
+
+
+def test_bootstrap_strict_mode_keeps_tuanziguardianclaw_preset_skill():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills"
+ for skill_name in [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "self-improving-agent",
+ "tuanziguardianclaw",
+ ]:
+ skill_dir = preset_skills_dir / skill_name
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(f"{skill_name}\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(preset_skills_dir)
+ env["OPENCLAW_EXEC_STRICT_MODE"] = "true"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ synced_skills = sorted(path.name for path in (Path(tmpdir) / "skills").iterdir() if path.is_dir())
+ assert synced_skills == [
+ "agent-browser-clawdbot",
+ "clawhub-store",
+ "kdocs",
+ "self-improving-agent",
+ "tuanziguardianclaw",
+ ]
+ cfg = json.loads(config_path.read_text())
+ assert cfg["skills"]["allowBundled"] == [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "wps365-skill",
+ "self-improving-agent",
+ "tuanziguardianclaw",
+ ]
+
+
+def test_bootstrap_overrides_stale_bundled_skill_allowlist_from_existing_config():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "skills": {
+ "allowBundled": [
+ "clawhub-store",
+ "tavily-search",
+ "agent-reach",
+ ]
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["skills"]["allowBundled"] == [
+ "clawhub-store",
+ "agent-browser-clawdbot",
+ "kdocs",
+ "wps365-skill",
+ ]
+
+
+def test_bootstrap_enables_self_improvement_workspace_files():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ preset_skills_dir = Path(tmpdir) / "preset-skills" / "self-improving-agent" / ".learnings"
+ preset_skills_dir.mkdir(parents=True, exist_ok=True)
+ (preset_skills_dir / "LEARNINGS.md").write_text("learning template\n")
+ (preset_skills_dir / "ERRORS.md").write_text("error template\n")
+ (preset_skills_dir / "FEATURE_REQUESTS.md").write_text("feature template\n")
+ (preset_skills_dir.parent / "SKILL.md").write_text("self-improving-agent\n")
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_PRESET_SKILLS_DIR"] = str(Path(tmpdir) / "preset-skills")
+ env["OPENCLAW_PRESET_SKILLS_ALLOWLIST"] = "self-improving-agent"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ workspace_learnings = Path(tmpdir) / "workspace" / ".learnings"
+ assert (workspace_learnings / "LEARNINGS.md").read_text() == "learning template\n"
+ assert (workspace_learnings / "ERRORS.md").read_text() == "error template\n"
+ assert (workspace_learnings / "FEATURE_REQUESTS.md").read_text() == "feature template\n"
+
+
+def test_bootstrap_patches_runtime_bundles_for_loopback_gateway_clients():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "server.impl-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n'
+ )
+ server_bundle.write_text(
+ 'function createGatewayHttpServer(opts) {\n'
+ '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n'
+ '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n'
+ '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n'
+ '\tasync function handleRequest(req, res) {\n'
+ '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n'
+ '\t\tconst requestStages = [{\n'
+ '\t\t\tname: "hooks",\n'
+ '\t\t\trun: () => handleHooksRequest(req, res)\n'
+ '\t\t}];\n'
+ '\t\tif (controlUiEnabled) {\n'
+ '\t\t\trequestStages.push({\n'
+ '\t\t\t\tname: "control-ui-http",\n'
+ '\t\t\t\trun: async () => (await getControlUiModule()).handleControlUiHttpRequest(req, res, {\n'
+ '\t\t\t\t\tbasePath: controlUiBasePath,\n'
+ '\t\t\t\t\tconfig: configSnapshot,\n'
+ '\t\t\t\t\tagentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,\n'
+ '\t\t\t\t\troot: controlUiRoot\n'
+ '\t\t\t\t})\n'
+ '\t\t\t});\n'
+ '\t\t}\n'
+ '\t}\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert 'internalTrustedProxyUser' in client_bundle.read_text()
+ gateway_source = gateway_bundle.read_text()
+ assert 'usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy"' in gateway_source
+ assert 'const usesDeviceTokenAuth = params.authMethod === "device-token";' in gateway_source
+ assert 'usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth' in gateway_source
+ assert 'const internalLoopbackUserHeader = String(process.env.OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER || process.env.OPENCLAW_TRUSTED_PROXY_USER_HEADER || "x-forwarded-user").trim().toLowerCase();' in gateway_source
+ assert 'const loopbackUser = headerValue(req.headers[internalLoopbackUserHeader || "x-forwarded-user"]);' in gateway_source
+ assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in gateway_source
+ assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in gateway_source
+ assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in gateway_source
+ assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in gateway_source
+ assert 'function shouldAttachDeviceIdentityForGatewayCall(params) {' in gateway_source
+ assert '].includes(parsed.hostname)) return false;' in gateway_source
+ assert '}) ? loadOrCreateDeviceIdentity() : null,' in gateway_source
+ assert 'const parsed = new URL(params.urlOverride);' in gateway_source
+ assert 'if (["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)) return;' in gateway_source
+ assert 'const keepUnboundScopes = !device && decision.kind === "allow" && authMethod === "trusted-proxy" && !hasBrowserOriginHeader;' in gateway_source
+ server_source = server_bundle.read_text()
+ assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' not in server_source
+ assert 'name: "workspace-files-proxy"' not in server_source
+ assert 'requestUrl.pathname.startsWith("/_ksadk/workspace/v1/")' not in server_source
+ assert 'const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`' not in server_source
+ assert 'this.ws.addEventListener(`open`,()=>{this.lastSeq=null,this.queueConnect()})' in control_ui_bundle.read_text()
+
+
+def test_bootstrap_patches_allow_loopback_runtime_for_forwarded_trusted_proxy_chain():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "server.impl-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'function authorizeTrustedProxy(params) {\n'
+ '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n'
+ '\tconst remoteAddr = req.socket?.remoteAddress;\n'
+ '\tif (isLoopbackAddress(remoteAddr) && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n'
+ '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n'
+ '}\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n'
+ )
+ server_bundle.write_text(
+ 'function createGatewayHttpServer(opts) {\n'
+ '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n'
+ '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n'
+ '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n'
+ '\tasync function handleRequest(req, res) {\n'
+ '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n'
+ '\t\tconst requestStages = [{\n'
+ '\t\t\tname: "hooks",\n'
+ '\t\t\trun: () => handleHooksRequest(req, res)\n'
+ '\t\t}];\n'
+ '\t\tif (controlUiEnabled) {\n'
+ '\t\t\trequestStages.push({\n'
+ '\t\t\t\tname: "control-ui-http",\n'
+ '\t\t\t\trun: async () => (await getControlUiModule()).handleControlUiHttpRequest(req, res, {\n'
+ '\t\t\t\t\tbasePath: controlUiBasePath,\n'
+ '\t\t\t\t\tconfig: configSnapshot,\n'
+ '\t\t\t\t\tagentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,\n'
+ '\t\t\t\t\troot: controlUiRoot\n'
+ '\t\t\t\t})\n'
+ '\t\t\t});\n'
+ '\t\t}\n'
+ '\t}\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ gateway_source = gateway_bundle.read_text()
+ assert 'if (isLoopbackAddress(remoteAddr) && trustedProxyConfig.allowLoopback !== true) {' in gateway_source
+ assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in gateway_source
+ assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in gateway_source
+ assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in gateway_source
+ assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in gateway_source
+
+
+def test_bootstrap_patches_openclaw_2026_5_18_split_auth_and_message_handler_runtime():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ auth_bundle = dist_dir / "auth-test.js"
+ message_handler_bundle = dist_dir / "message-handler-test.js"
+ gateway_call_bundle = dist_dir / "gateway-call-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ auth_bundle.write_text(
+ 'function authorizeTrustedProxy(params) {\n'
+ '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n'
+ '\tif (!req) return { reason: "trusted_proxy_no_request" };\n'
+ '\tconst remoteAddr = req.socket?.remoteAddress;\n'
+ '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n'
+ '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n'
+ '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n'
+ '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n'
+ '}\n'
+ )
+ message_handler_bundle.write_text(
+ 'function shouldSkipLocalBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n'
+ '\tif (params.authMethod === "none") return true;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n'
+ '}\n'
+ )
+ gateway_call_bundle.write_text(
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ auth_source = auth_bundle.read_text()
+ assert 'if (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) {' in auth_source
+ assert 'const forwardedLoopbackChain = String(headerValue(req.headers["x-forwarded-for"]) || "").split(",").map((value) => value.trim()).filter(Boolean);' in auth_source
+ assert 'const trustedProxyAddressCheck = typeof isTrustedProxyAddress === "function" ? isTrustedProxyAddress : typeof isTrustedProxyAddress$1 === "function" ? isTrustedProxyAddress$1 : null;' in auth_source
+ assert 'const forwardedLoopbackTrusted = !!trustedProxyAddressCheck && forwardedLoopbackChain.some((addr) => !isLoopbackAddress(addr) && trustedProxyAddressCheck(addr, trustedProxies));' in auth_source
+ assert 'if (!forwardedLoopbackTrusted && (!internalLoopbackUser || !loopbackUser || loopbackUser.trim() !== internalLoopbackUser)) return { reason: "trusted_proxy_loopback_source" };' in auth_source
+ message_handler_source = message_handler_bundle.read_text()
+ assert 'const usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy";' in message_handler_source
+ assert 'return usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;' in message_handler_source
+
+
+def test_bootstrap_patches_openclaw_2026_5_26_control_ui_trusted_proxy_scopes():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ auth_bundle = dist_dir / "auth-test.js"
+ message_handler_bundle = dist_dir / "message-handler-test.js"
+ gateway_call_bundle = dist_dir / "gateway-call-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ auth_bundle.write_text(
+ 'function authorizeTrustedProxy(params) {\n'
+ '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n'
+ '\tif (!req) return { reason: "trusted_proxy_no_request" };\n'
+ '\tconst remoteAddr = req.socket?.remoteAddress;\n'
+ '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n'
+ '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n'
+ '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n'
+ '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n'
+ '}\n'
+ )
+ message_handler_bundle.write_text(
+ 'function shouldSkipLocalBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n'
+ '\tif (params.authMethod === "none") return true;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n'
+ '}\n'
+ 'function shouldClearUnboundScopesForMissingDeviceIdentity(params) {\n'
+ '\treturn params.decision.kind !== "allow" || !params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy");\n'
+ '}\n'
+ )
+ gateway_call_bundle.write_text(
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ message_handler_source = message_handler_bundle.read_text()
+ assert 'const usesLoopbackTrustedProxyAuth = params.authMethod === "trusted-proxy";' in message_handler_source
+ assert 'return usesLoopbackTrustedProxyAuth || params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;' in message_handler_source
+ assert '!params.trustedProxyAuthOk' in message_handler_source
+
+
+def test_bootstrap_patches_openclaw_2026_5_26_config_schema_full_response_budget():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ auth_bundle = dist_dir / "auth-test.js"
+ message_handler_bundle = dist_dir / "message-handler-test.js"
+ gateway_call_bundle = dist_dir / "gateway-call-test.js"
+ config_bundle = dist_dir / "config-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ auth_bundle.write_text(
+ 'function authorizeTrustedProxy(params) {\n'
+ '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n'
+ '\tif (!req) return { reason: "trusted_proxy_no_request" };\n'
+ '\tconst remoteAddr = req.socket?.remoteAddress;\n'
+ '\tif (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n'
+ '\tconst remoteIsLoopback = isLoopbackAddress(remoteAddr);\n'
+ '\tif (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };\n'
+ '\treturn { user: headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]).trim() };\n'
+ '}\n'
+ )
+ message_handler_bundle.write_text(
+ 'function shouldSkipLocalBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tif (!(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") || params.hasBrowserOriginHeader) return false;\n'
+ '\tif (params.authMethod === "none") return true;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth;\n'
+ '}\n'
+ 'function shouldClearUnboundScopesForMissingDeviceIdentity(params) {\n'
+ '\treturn params.decision.kind !== "allow" || !params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy");\n'
+ '}\n'
+ )
+ gateway_call_bundle.write_text(
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ )
+ config_bundle.write_text(
+ 'function loadSchemaWithPlugins() {\n'
+ '\treturn loadGatewayRuntimeConfigSchema();\n'
+ '}\n'
+ 'const configHandlers = {\n'
+ '\t"config.get": async ({ params, respond }) => {\n'
+ '\t\trespond(true, redactConfigSnapshot(await readConfigFileSnapshot(), loadSchemaWithPlugins().uiHints), void 0);\n'
+ '\t},\n'
+ '\t"config.schema": ({ params, respond }) => {\n'
+ '\t\tif (!assertValidParams(params, validateConfigSchemaParams, "config.schema", respond)) return;\n'
+ '\t\trespond(true, loadSchemaWithPlugins(), void 0);\n'
+ '\t},\n'
+ '\t"config.schema.lookup": ({ params, respond, context }) => {\n'
+ '\t\tconst result = lookupConfigSchema(loadSchemaWithPlugins(), params.path, resolveConfigReloadMetadata);\n'
+ '\t\trespond(true, result, void 0);\n'
+ '\t}\n'
+ '};\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ config_source = config_bundle.read_text()
+ assert "compactConfigSchemaResponseForAgentEngineGateway" in config_source
+ assert (
+ "respond(true, compactConfigSchemaResponseForAgentEngineGateway(loadSchemaWithPlugins()), void 0);"
+ in config_source
+ )
+ assert (
+ "lookupConfigSchema(loadSchemaWithPlugins(), params.path, resolveConfigReloadMetadata)"
+ in config_source
+ )
+
+
+def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_4_26_shape():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "server.impl-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n'
+ )
+ server_bundle.write_text(
+ 'function createGatewayHttpServer(opts) {\n'
+ '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, trustedProxies, allowRealIpFallback, rateLimiter, getReadiness } = opts;\n'
+ '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n'
+ '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n'
+ '\tasync function handleRequest(req, res) {\n'
+ '\t\tconst scopedRequestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n'
+ '\t\tconst requestStages = [{\n'
+ '\t\t\t\tname: "gateway-probes",\n'
+ '\t\t\t\trun: () => handleGatewayProbeRequest(req, res, scopedRequestPath, resolvedAuth, trustedProxies, allowRealIpFallback, getReadiness)\n'
+ '\t\t\t}, {\n'
+ '\t\t\t\tname: "hooks",\n'
+ '\t\t\t\trun: () => handleHooksRequest(req, res)\n'
+ '\t\t\t}];\n'
+ '\t\t\tif (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({\n'
+ '\t\t\tname: "models",\n'
+ '\t\t\trun: async () => (await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, {\n'
+ '\t\t\t\tauth: resolvedAuth,\n'
+ '\t\t\t\ttrustedProxies,\n'
+ '\t\t\t\tallowRealIpFallback,\n'
+ '\t\t\t\trateLimiter\n'
+ '\t\t\t})\n'
+ '\t\t});\n'
+ '\t}\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ server_source = server_bundle.read_text()
+ assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' in server_source
+ assert 'name: "workspace-files-proxy"' in server_source
+ assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source
+ assert 'if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({' in server_source
+
+
+def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_6_1_shape():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "server.impl-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n'
+ )
+ server_bundle.write_text(
+ 'function createGatewayHttpServer(opts) {\n'
+ '\tconst { canvasHost, clients, controlUiEnabled, controlUiBasePath, controlUiRoot, openAiChatCompletionsEnabled, openAiChatCompletionsConfig, openResponsesEnabled, openResponsesConfig, strictTransportSecurityHeader, handleHooksRequest, handlePluginRequest, shouldEnforcePluginGatewayAuth, resolvedAuth, rateLimiter, getReadiness } = opts;\n'
+ '\tconst getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);\n'
+ '\tconst openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;\n'
+ '\tasync function handleRequest(req, res) {\n'
+ '\t\tconst scopedNodeCapability = normalizePluginNodeCapabilityScopedUrl(req.url ?? "/");\n'
+ '\t\tif (scopedNodeCapability.rewrittenUrl) req.url = scopedNodeCapability.rewrittenUrl;\n'
+ '\t\tconst scopedRequestPath = scopedNodeCapability.pathname;\n'
+ '\t\tconst resolvedAuthValue = getResolvedAuth();\n'
+ '\t\tconst requestStages = [{\n'
+ '\t\t\t\tname: "gateway-probes",\n'
+ '\t\t\t\trun: () => handleGatewayProbeRequest(req, res, scopedRequestPath, resolvedAuthValue, trustedProxies, allowRealIpFallback, getReadiness)\n'
+ '\t\t\t}, {\n'
+ '\t\t\t\tname: "hooks",\n'
+ '\t\t\t\trun: () => handleHooksRequest(req, res)\n'
+ '\t\t\t}];\n'
+ '\t\t\tif (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) requestStages.push({\n'
+ '\t\t\tname: "models",\n'
+ '\t\t\trun: async () => (await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, {\n'
+ '\t\t\t\tauth: resolvedAuthValue,\n'
+ '\t\t\t\ttrustedProxies,\n'
+ '\t\t\t\tallowRealIpFallback,\n'
+ '\t\t\t\trateLimiter\n'
+ '\t\t\t})\n'
+ '\t\t});\n'
+ '\t}\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ server_source = server_bundle.read_text()
+ assert 'async function handleWorkspaceFilesProxyRequest(req, res) {' in server_source
+ assert 'name: "workspace-files-proxy"' in server_source
+ assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source
+ assert 'auth: resolvedAuthValue' in server_source
+
+
+def test_bootstrap_patches_workspace_proxy_stage_for_upstream_2026_3_28_shape():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "gateway-cli-old-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ )
+ server_bundle.write_text(
+ 'function createGatewayHttpServer(opts) {\n'
+ '\tasync function handleRequest(req, res) {\n'
+ '\t\tconst requestPath = new URL(req.url ?? "/", "http://localhost").pathname;\n'
+ '\t\tconst requestStages = [\n'
+ '\t\t\t\t{\n'
+ '\t\t\t\t\tname: "hooks",\n'
+ '\t\t\t\t\trun: () => handleHooksRequest(req, res)\n'
+ '\t\t\t\t},\n'
+ '\t\t\t\t{\n'
+ '\t\t\t\t\tname: "models",\n'
+ '\t\t\t\t\trun: () => openAiCompatEnabled ? handleOpenAiModelsHttpRequest(req, res, {\n'
+ '\t\t\t\t\t\tauth: resolvedAuth,\n'
+ '\t\t\t\t\t\ttrustedProxies,\n'
+ '\t\t\t\t\t\tallowRealIpFallback,\n'
+ '\t\t\t\t\t\trateLimiter\n'
+ '\t\t\t\t\t}) : false\n'
+ '\t\t\t\t},\n'
+ '\t\t];\n'
+ '\t}\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ server_source = server_bundle.read_text()
+ assert 'name: "workspace-files-proxy"' in server_source
+ assert 'run: () => handleWorkspaceFilesProxyRequest(req, res)' in server_source
+ assert 'name: "models"' in server_source
+
+
+def test_bootstrap_accepts_upstream_2026_3_28_loopback_gateway_runtime_logic():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ auth_bundle = dist_dir / "gateway-auth-test.js"
+ connect_policy_bundle = dist_dir / "connect-policy-test.js"
+ gateway_call_bundle = dist_dir / "call-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ auth_bundle.write_text(
+ 'function authorizeTrustedProxy(params) {\n'
+ '\tconst { req, trustedProxies, trustedProxyConfig } = params;\n'
+ '\tif (!req) return { reason: "trusted_proxy_no_request" };\n'
+ '\tconst remoteAddr = req.socket?.remoteAddress;\n'
+ '\tif (!remoteAddr || !isTrustedProxyAddress$1(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };\n'
+ '\tconst userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);\n'
+ '\treturn { user: userHeaderValue.trim() };\n'
+ '}\n'
+ )
+ connect_policy_bundle.write_text(
+ 'function shouldSkipControlUiPairing(policy, role, trustedProxyAuthOk = false, authMode) {\n'
+ '\tif (trustedProxyAuthOk) {\n'
+ '\t\treturn true;\n'
+ '\t}\n'
+ '\treturn role === "operator" && policy.allowBypass;\n'
+ '}\n'
+ )
+ gateway_call_bundle.write_text(
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert 'if (!remoteAddr || !isTrustedProxyAddress$1(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };' in auth_bundle.read_text()
+ assert 'if (trustedProxyAuthOk) {' in connect_policy_bundle.read_text()
+ assert 'const parsed = new URL(params.urlOverride);' in gateway_call_bundle.read_text()
+
+
+def test_bootstrap_disables_container_self_update_runtime_hooks():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ control_ui_assets_dir = dist_dir / "control-ui" / "assets"
+ control_ui_assets_dir.mkdir(parents=True, exist_ok=True)
+ client_bundle = dist_dir / "reply-test.js"
+ gateway_bundle = dist_dir / "gateway-cli-test.js"
+ server_bundle = dist_dir / "server-test.js"
+ control_ui_bundle = control_ui_assets_dir / "main-test.js"
+
+ client_bundle.write_text('const wsOptions = { maxPayload: 25 * 1024 * 1024 };')
+ gateway_bundle.write_text(
+ 'function shouldSkipBackendSelfPairing(params) {\n'
+ '\tif (!(params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND)) return false;\n'
+ '\tconst usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";\n'
+ '\tconst usesDeviceTokenAuth = params.authMethod === "device-token";\n'
+ '\treturn params.isLocalClient && !params.hasBrowserOriginHeader && (params.sharedAuthOk && usesSharedSecretAuth || usesDeviceTokenAuth);\n'
+ '}\n'
+ 'if (isLoopbackAddress(remoteAddr)) return { reason: "trusted_proxy_loopback_source" };\n'
+ 'function shouldAttachDeviceIdentityForGatewayCall(params) {\n'
+ '\treturn true;\n'
+ '}\n'
+ 'deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({\n'
+ '\t\t\t\turl,\n'
+ '\t\t\t\ttoken,\n'
+ '\t\t\t\tpassword\n'
+ '\t\t\t}) ? loadOrCreateDeviceIdentity() : void 0,\n'
+ 'function ensureExplicitGatewayAuth(params) {\n'
+ '\tif (!params.urlOverride) return;\n'
+ '\tconst explicitToken = params.explicitAuth?.token;\n'
+ '}\n'
+ 'if (!device && (!isControlUi || decision.kind !== "allow")) clearUnboundScopes();\n'
+ )
+ server_bundle.write_text(
+ 'let updateAvailableCache = null;\n'
+ 'function getUpdateAvailable() {\n'
+ '\treturn updateAvailableCache;\n'
+ '}\n'
+ 'function scheduleGatewayUpdateCheck(params) {\n'
+ '\tlet stopped = false;\n'
+ '\tlet timer = null;\n'
+ '\tlet running = false;\n'
+ '\tconst tick = async () => {\n'
+ '\t\tif (stopped || running) return;\n'
+ '\t\trunning = true;\n'
+ '\t\ttry {\n'
+ '\t\t\tawait runGatewayUpdateCheck(params);\n'
+ '\t\t} catch {} finally {\n'
+ '\t\t\trunning = false;\n'
+ '\t\t}\n'
+ '\t\tif (stopped) return;\n'
+ '\t\tconst intervalMs = resolveCheckIntervalMs(params.cfg);\n'
+ '\t\ttimer = setTimeout(() => {\n'
+ '\t\t\ttick();\n'
+ '\t\t}, intervalMs);\n'
+ '\t};\n'
+ '\ttick();\n'
+ '\treturn () => {\n'
+ '\t\tstopped = true;\n'
+ '\t\tif (timer) {\n'
+ '\t\t\tclearTimeout(timer);\n'
+ '\t\t\ttimer = null;\n'
+ '\t\t}\n'
+ '\t};\n'
+ '}\n'
+ )
+ control_ui_bundle.write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_WORKSPACE_FILES_ENABLED"] = "0"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ patched_source = server_bundle.read_text()
+ assert 'function getUpdateAvailable() {\n\treturn null;\n}' in patched_source
+ assert 'function scheduleGatewayUpdateCheck(params) {\n\treturn () => {};\n}' in patched_source
+
+
+def test_bootstrap_fails_when_required_runtime_patch_targets_are_missing():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ dist_dir = Path(tmpdir) / "dist"
+ dist_dir.mkdir(parents=True, exist_ok=True)
+ marker_file = dist_dir / ".agentengine-dist-marker"
+ (dist_dir / "control-ui-only.js").write_text('this.ws.addEventListener(`open`,()=>this.queueConnect())')
+
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DIST_DIR"] = str(dist_dir)
+ env["OPENCLAW_DIST_PATCH_MARKER"] = str(marker_file)
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ combined = result.stderr or result.stdout
+ assert (
+ "required dist patches missing:" in combined
+ or "必需的 dist 补丁缺失:" in combined
+ )
+ assert not marker_file.exists()
+
+
+def test_bootstrap_dist_patch_registry_uses_capability_group_and_variant_metadata():
+ bootstrap = BOOTSTRAP_SCRIPT.read_text(encoding="utf-8")
+
+ assert "const requiredCapabilities = new Set([" in bootstrap
+ assert "capability:" in bootstrap
+ assert "group:" in bootstrap
+ assert "variant:" in bootstrap
+ assert "why:" in bootstrap
+ assert "since:" in bootstrap
+ assert "按能力验证必需补丁" in bootstrap
+ assert "缺失的必需能力" in bootstrap
+ assert "requiredLabels" not in bootstrap
+ assert "patchedLabels" not in bootstrap
+
+
+def test_bootstrap_defaults_state_dir_under_home_for_non_root_runtime():
+ with TemporaryDirectory() as tmpdir:
+ home_dir = Path(tmpdir) / "home" / "node"
+ home_dir.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ env.pop("OPENCLAW_MODEL_API_KEY", None)
+ env.pop("OPENAI_API_KEY", None)
+ env.pop("OPENCLAW_STATE_DIR", None)
+ env.pop("OPENCLAW_CONFIG_PATH", None)
+ env["HOME"] = str(home_dir)
+ env["OPENCLAW_BOOTSTRAP_ONLY"] = "1"
+ env["OPENCLAW_MODEL_PROVIDER_ID"] = "ksyun"
+ env["OPENCLAW_MODEL_BASE_URL"] = "http://example.test/v1"
+ env["OPENCLAW_DEFAULT_MODEL"] = "ksyun/glm-5.1"
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ state_dir = home_dir / ".openclaw"
+ config_path = state_dir / "openclaw.json"
+ secrets_path = state_dir / "secrets.json"
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert config_path.exists()
+ assert secrets_path.exists()
+ cfg = json.loads(config_path.read_text())
+ assert cfg["agents"]["defaults"]["workspace"] == str(state_dir / "workspace")
+ assert cfg["secrets"]["providers"]["default"]["path"] == str(secrets_path)
+
+
+def test_bootstrap_applies_mem0_memory_backend_manifest():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ default_extensions_dir = Path(tmpdir) / "default-extensions" / "openclaw-mem0"
+ default_extensions_dir.mkdir(parents=True, exist_ok=True)
+ (default_extensions_dir / "manifest.json").write_text('{"name":"openclaw-mem0"}\n')
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["OPENCLAW_DEFAULT_EXTENSIONS_DIR"] = str(Path(tmpdir) / "default-extensions")
+ env["MEMORY_BACKEND_MANIFEST"] = _build_mem0_manifest_json()
+ env["MEM0_API_KEY"] = f"2000104981.{VALID_MEM0_UUID}:mem0-secret"
+ env["MEM0_USER_ID"] = "2000104981"
+ env["MEM0_BASE_URL"] = "http://mem-service.example.test"
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert (Path(tmpdir) / "extensions" / "openclaw-mem0" / "manifest.json").exists()
+ assert cfg["plugins"]["slots"]["memory"] == "openclaw-mem0"
+ assert "openclaw-mem0" in cfg["plugins"]["allow"]
+ assert cfg["plugins"]["entries"]["openclaw-mem0"] == {
+ "enabled": True,
+ "config": {
+ "mode": "platform",
+ "apiKey": f"2000104981.{VALID_MEM0_UUID}:mem0-secret",
+ "baseUrl": "http://mem-service.example.test",
+ "userId": "2000104981",
+ },
+ }
+
+
+def test_bootstrap_openclaw_default_manifest_clears_existing_mem0_memory_backend():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "plugins": {
+ "slots": {"memory": "openclaw-mem0", "search": "perplexity"},
+ "allow": ["openclaw-mem0", "perplexity"],
+ "entries": {
+ "openclaw-mem0": {
+ "enabled": True,
+ "config": {
+ "mode": "platform",
+ "apiKey": "old-key",
+ "baseUrl": "http://mem-service.example.test",
+ "userId": "2000104981",
+ },
+ },
+ "perplexity": {"enabled": True},
+ },
+ }
+ }
+ )
+ )
+ env = _build_base_env(tmpdir, str(config_path))
+ env["OPENCLAW_MODEL_API_KEY"] = "dummy-secret-value"
+ env["MEMORY_BACKEND_MANIFEST"] = _build_openclaw_default_memory_manifest_json()
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr or result.stdout
+ cfg = json.loads(config_path.read_text())
+ assert cfg["plugins"]["slots"] == {"search": "perplexity"}
+ assert "openclaw-mem0" not in cfg["plugins"]["allow"]
+ assert cfg["plugins"]["entries"]["openclaw-mem0"] == {
+ "enabled": False,
+ "config": {},
+ }
+ assert cfg["plugins"]["entries"]["perplexity"] == {"enabled": True}
+
+
+def test_bootstrap_fails_when_mem0_manifest_env_is_incomplete():
+ with TemporaryDirectory() as tmpdir:
+ config_path = Path(tmpdir) / "openclaw.json"
+ env = _build_base_env(tmpdir, str(config_path))
+ env["MEMORY_BACKEND_MANIFEST"] = _build_mem0_manifest_json()
+
+ result = subprocess.run(
+ ["bash", str(BOOTSTRAP_SCRIPT)],
+ cwd=str(REPO_ROOT),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode != 0
+ combined = f"{result.stdout}\n{result.stderr}"
+ assert (
+ "MEM0_API_KEY" in combined
+ or "MEM0_USER_ID" in combined
+ or "MEM0_BASE_URL" in combined
+ )
diff --git a/tests/test_server_session_app.py b/tests/test_server_session_app.py
new file mode 100644
index 0000000..179b219
--- /dev/null
+++ b/tests/test_server_session_app.py
@@ -0,0 +1,1815 @@
+from __future__ import annotations
+
+import base64
+import asyncio
+import importlib
+import json
+from types import SimpleNamespace
+
+import httpx
+import pytest
+
+from ksadk.runners.base_runner import BaseRunner
+from ksadk.server.api_models import AgentRunRequest, InlineData, Part
+from ksadk.sessions.base import SessionEvent
+from ksadk.sessions.in_memory import InMemorySessionService
+
+
+class _DummyRunner(BaseRunner):
+ def __init__(self):
+ super().__init__(
+ detection_result=SimpleNamespace(
+ name="demo-agent",
+ type=SimpleNamespace(value="mock"),
+ ),
+ project_dir=".",
+ )
+ self.calls: list[dict] = []
+
+ def load_agent(self) -> None:
+ return None
+
+ async def invoke(self, input_data: dict) -> dict:
+ self.calls.append(input_data)
+ return {"output": "assistant says hi"}
+
+ async def stream(self, input_data: dict):
+ yield {"type": "final", "output": "assistant says hi"}
+
+
+class _OverrideStreamingRunner(BaseRunner):
+ def __init__(self):
+ super().__init__(
+ detection_result=SimpleNamespace(
+ name="demo-agent",
+ type=SimpleNamespace(value="mock"),
+ ),
+ project_dir=".",
+ )
+
+ def load_agent(self) -> None:
+ return None
+
+ async def invoke(self, input_data: dict) -> dict:
+ return {"output": "goodbye"}
+
+ async def stream(self, input_data: dict):
+ yield {"type": "text", "delta": "hel"}
+ yield {"type": "text", "delta": "lo"}
+ yield {"type": "final", "output": "goodbye"}
+
+
+class _ThinkingOnlyFinalRunner(_OverrideStreamingRunner):
+ async def stream(self, input_data: dict):
+ yield {"type": "thinking", "delta": "先想一下"}
+ yield {"type": "final", "output": "final answer"}
+
+
+class _SlowStreamingRunner(_OverrideStreamingRunner):
+ async def stream(self, input_data: dict):
+ yield {"type": "text", "delta": "hel"}
+ await asyncio.sleep(0.05)
+ yield {"type": "text", "delta": "lo"}
+ yield {"type": "final", "output": "hello"}
+
+
+class _ModelAwareRunner(_DummyRunner):
+ def __init__(self):
+ super().__init__()
+ self.prepared_models: list[str | None] = []
+
+ def prepare_for_request(self, model: str | None) -> None:
+ self.prepared_models.append(model)
+
+
+class _ExternalModelsAsyncClient:
+ """给 ListAgentModels 用的外部模型目录假客户端。"""
+
+ def __init__(self, *args, payload=None, error: Exception | None = None, **kwargs):
+ self._payload = payload
+ self._error = error
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ async def get(self, url: str, headers: dict | None = None):
+ if self._error is not None:
+ raise self._error
+ request = httpx.Request("GET", url, headers=headers)
+ return httpx.Response(200, json=self._payload, request=request)
+
+
+def _sse_payloads(response_text: str) -> list[dict]:
+ return [
+ json.loads(line.removeprefix("data: "))
+ for line in response_text.splitlines()
+ if line.startswith("data: ")
+ ]
+
+
+def _sse_events(response_text: str) -> list[tuple[str, dict]]:
+ current_event = "message"
+ events: list[tuple[str, dict]] = []
+ for line in response_text.splitlines():
+ if line.startswith("event: "):
+ current_event = line.removeprefix("event: ").strip() or "message"
+ continue
+ if not line.startswith("data: "):
+ continue
+ payload = line.removeprefix("data: ").strip()
+ if not payload or payload == "[DONE]":
+ current_event = "message"
+ continue
+ events.append((current_event, json.loads(payload)))
+ current_event = "message"
+ return events
+
+
+@pytest.mark.asyncio
+async def test_run_sse_uses_new_session_service(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId=None,
+ newMessage={"role": "user", "parts": [{"text": "hello"}]},
+ streaming=False,
+ stateDelta={"topic": "billing"},
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ first_line = next(line for line in response.text.splitlines() if line.startswith("data: "))
+ payload = json.loads(first_line.removeprefix("data: "))
+ session_id = payload["sessionId"]
+
+ session = await service.get_session(session_id)
+ assert session is not None
+ assert session.state == {"topic": "billing"}
+
+ events = await service.get_events(session_id)
+ assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
+ assert [event.event_type for event in events] == [
+ "user_message",
+ "run_status",
+ "assistant_message",
+ "run_status",
+ ]
+ assert events[0].content["parts"][0]["text"] == "hello"
+ assert events[2].content["parts"][0]["text"] == "assistant says hi"
+ assert events[0].metadata["agent_input"] == "hello"
+
+ assert runner.calls == [
+ {
+ "session_id": session_id,
+ "input": "hello",
+ "history": [{"role": "user", "content": "hello"}],
+ "input_content": [{"type": "input_text", "text": "hello"}],
+ "input_messages": [
+ {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}
+ ],
+ "input_parts": [{"text": "hello"}],
+ "attachments": [],
+ "attachment_results": [],
+ "current_attachments": [],
+ "current_attachment_results": [],
+ "has_current_files": False,
+ "model": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_run_sse_passes_attachment_results_to_runner(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId=None,
+ newMessage={
+ "role": "user",
+ "parts": [
+ {"text": "请分析附件"},
+ Part(
+ inlineData=InlineData(
+ displayName="resume.txt",
+ mimeType="text/plain",
+ data=base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"),
+ )
+ ).model_dump(exclude_none=True),
+ ],
+ },
+ streaming=False,
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ assert runner.calls[-1]["current_attachments"] == [
+ {
+ "display_name": "resume.txt",
+ "mime_type": "text/plain",
+ "transport": "inline",
+ "data": base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"),
+ "is_text": True,
+ "size_bytes": len("候选人简历内容".encode("utf-8")),
+ }
+ ]
+ assert runner.calls[-1]["current_attachment_results"] == [
+ {
+ "display_name": "resume.txt",
+ "mime_type": "text/plain",
+ "transport": "inline",
+ "file_uri": "",
+ "size_bytes": len("候选人简历内容".encode("utf-8")),
+ "kind": "text",
+ "status": "ok",
+ "warnings": [],
+ "extraction_method": "text_decode",
+ "text_excerpt": "候选人简历内容",
+ "text": "候选人简历内容",
+ }
+ ]
+ assert runner.calls[-1]["has_current_files"] is True
+ assert runner.calls[-1]["attachment_results"] == [
+ {
+ "display_name": "resume.txt",
+ "mime_type": "text/plain",
+ "transport": "inline",
+ "file_uri": "",
+ "size_bytes": len("候选人简历内容".encode("utf-8")),
+ "kind": "text",
+ "status": "ok",
+ "warnings": [],
+ "extraction_method": "text_decode",
+ "text_excerpt": "候选人简历内容",
+ "text": "候选人简历内容",
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_create_session_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ await service.create_session(
+ agent_id="other-agent",
+ user_id="other-user",
+ session_id="shared-session",
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/apps/demo-agent/users/user-1/sessions",
+ json={"sessionId": "shared-session"},
+ )
+
+ assert response.status_code == 409
+ assert "different agent or user" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_run_sse_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+ await service.create_session(
+ agent_id="other-agent",
+ user_id="other-user",
+ session_id="shared-session",
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId="shared-session",
+ newMessage={"role": "user", "parts": [{"text": "hello"}]},
+ streaming=False,
+ ).model_dump(),
+ )
+
+ assert response.status_code == 409
+ assert "different agent or user" in response.json()["detail"]
+ assert runner.calls == []
+
+
+@pytest.mark.asyncio
+async def test_attachment_content_route_serves_uploaded_binary(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ upload_response = await client.post(
+ "/agentengine/api/v1/UploadFile",
+ files={"file": ("arch.png", b"\x89PNG\r\n\x1a\nbinary", "image/png")},
+ )
+
+ assert upload_response.status_code == 200
+ file_uri = upload_response.json()["Data"]["FileData"]["fileUri"]
+
+ content_response = await client.get(
+ "/agentengine/api/v1/AttachmentContent",
+ params={"FileUri": file_uri},
+ )
+
+ assert content_response.status_code == 200
+ assert content_response.headers["content-type"].startswith("image/png")
+ assert content_response.content == b"\x89PNG\r\n\x1a\nbinary"
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_routes_use_state_dir_workspace_root(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ workspace_dir.mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "existing").mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8")
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ list_response = await client.get("/_ksadk/workspace/v1/entries", params={"path": "."})
+ upload_response = await client.post(
+ "/_ksadk/workspace/v1/files/uploads/report.txt",
+ files={"file": ("report.txt", b"workspace upload", "text/plain")},
+ )
+ download_response = await client.get("/_ksadk/workspace/v1/files/uploads/report.txt")
+
+ assert list_response.status_code == 200
+ list_payload = list_response.json()
+ assert list_payload["Root"] == "workspace"
+ assert list_payload["Path"] == "."
+ assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"}
+ assert list_payload["Entries"][0]["Type"] == "directory"
+
+ assert upload_response.status_code == 200
+ assert upload_response.json()["Entry"]["Path"] == "uploads/report.txt"
+ assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload"
+
+ assert download_response.status_code == 200
+ assert download_response.content == b"workspace upload"
+ assert download_response.headers["content-type"].startswith("text/plain")
+
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ delete_response = await client.delete("/_ksadk/workspace/v1/files/uploads/report.txt")
+
+ assert delete_response.status_code == 200
+ assert delete_response.json() == {"Deleted": True}
+ assert not (workspace_dir / "uploads" / "report.txt").exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_routes_delete_empty_directory(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ empty_dir = workspace_dir / "empty-folder"
+ empty_dir.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder")
+
+ assert runtime_response.status_code == 200
+ assert runtime_response.json() == {"Deleted": True}
+ assert not empty_dir.exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_routes_delete_empty_directory_with_trailing_slash(
+ monkeypatch,
+ tmp_path,
+):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ empty_dir = workspace_dir / "empty-folder"
+ empty_dir.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder/")
+
+ assert runtime_response.status_code == 200
+ assert runtime_response.json() == {"Deleted": True}
+ assert not empty_dir.exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_action_route_deletes_empty_directory(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ empty_dir = workspace_dir / "empty-folder"
+ empty_dir.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ action_response = await client.post(
+ "/agentengine/api/v1/DeleteWorkspaceFile",
+ json={"AgentId": "demo-agent", "Path": "empty-folder"},
+ )
+
+ assert action_response.status_code == 200
+ assert action_response.json()["Data"] == {"Deleted": True}
+ assert not empty_dir.exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_routes_reject_non_empty_directory_delete(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ non_empty_dir = workspace_dir / "docs"
+ non_empty_dir.mkdir(parents=True, exist_ok=True)
+ (non_empty_dir / "readme.txt").write_text("keep me", encoding="utf-8")
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.delete("/_ksadk/workspace/v1/files/docs")
+
+ assert response.status_code == 409
+ assert response.json()["detail"] == "workspace directory is not empty"
+ assert (non_empty_dir / "readme.txt").exists()
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_route_serves_html_preview_inline(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ workspace_dir.mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "showcase").mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "showcase" / "index.html").write_text(
+ 'Features',
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.get("/_ksadk/workspace/v1/files/showcase/index.html")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert "content-disposition" not in response.headers
+ csp = response.headers.get("content-security-policy", "")
+ assert "sandbox allow-scripts allow-downloads" in csp
+ assert "style-src 'unsafe-inline' data: 'self' https:" in csp
+ assert "img-src data: blob: 'self' https:" in csp
+ assert "connect-src 'none'" in csp
+ assert '' in response.text
+ assert "data-ksadk-preview-anchor-handler" in response.text
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_runtime_routes_reject_path_escape(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.get(
+ "/_ksadk/workspace/v1/entries",
+ params={"path": "../outside"},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "workspace path escapes the workspace root"
+
+
+@pytest.mark.asyncio
+async def test_workspace_files_action_routes_match_runtime_contract(monkeypatch, tmp_path):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ ui_dir = tmp_path / ".agentengine" / "ui"
+ workspace_dir = ui_dir / "workspace"
+ workspace_dir.mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "existing").mkdir(parents=True, exist_ok=True)
+ (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8")
+ monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
+
+ service = InMemorySessionService()
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ list_response = await client.post(
+ "/agentengine/api/v1/ListWorkspaceFiles",
+ json={"AgentId": "demo-agent", "Path": "."},
+ )
+ upload_response = await client.post(
+ "/agentengine/api/v1/AddWorkspaceFile",
+ data={"AgentId": "demo-agent", "Path": "uploads/report.txt"},
+ files={"file": ("report.txt", b"workspace upload", "text/plain")},
+ )
+ download_response = await client.get(
+ "/agentengine/api/v1/GetWorkspaceFileContent",
+ params={"AgentId": "demo-agent", "FilePath": "uploads/report.txt"},
+ )
+
+ assert list_response.status_code == 200
+ list_payload = list_response.json()["Data"]
+ assert list_payload["Root"] == "workspace"
+ assert list_payload["Path"] == "."
+ assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"}
+
+ assert upload_response.status_code == 200
+ assert upload_response.json()["Data"]["Entry"]["Path"] == "uploads/report.txt"
+ assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload"
+
+ assert download_response.status_code == 200
+ assert download_response.content == b"workspace upload"
+ assert download_response.headers["content-type"].startswith("text/plain")
+
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ delete_response = await client.post(
+ "/agentengine/api/v1/DeleteWorkspaceFile",
+ json={"AgentId": "demo-agent", "Path": "uploads/report.txt"},
+ )
+
+ assert delete_response.status_code == 200
+ assert delete_response.json()["Data"] == {"Deleted": True}
+ assert not (workspace_dir / "uploads" / "report.txt").exists()
+
+
+@pytest.mark.asyncio
+async def test_list_sessions_projects_heuristic_title_for_existing_fallback_session(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ created = await service.create_session(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id="sess-heuristic-read",
+ )
+ await service.update_session_metadata(
+ created.id,
+ title="你好,请介绍一下你自己",
+ title_source="fallback_first_prompt",
+ first_prompt="你好,请介绍一下你自己",
+ summary="你好!我是企业高端招聘全流程助手,可以协助你完成职位分析、候选人筛选和面试建议生成。",
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/ListSessions",
+ json={"AgentId": "demo-agent", "UserId": "user-1"},
+ )
+
+ assert response.status_code == 200
+ session = response.json()["Data"]["Sessions"][0]
+ assert session["Title"] == "招聘助手能力"
+ assert session["TitleSource"] == "heuristic"
+
+
+@pytest.mark.asyncio
+async def test_session_actions_do_not_return_inline_attachment_data_in_state(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ session = await service.create_session(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id="sess-inline-state",
+ )
+ await service.update_state(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id=session.id,
+ scope="session",
+ state_delta={
+ "__ksadk_attachment_context__": {
+ "attachments": [
+ {
+ "display_name": "photo.png",
+ "mime_type": "image/png",
+ "transport": "inline",
+ "data": base64.b64encode(b"image bytes").decode("ascii"),
+ "size_bytes": 11,
+ }
+ ],
+ "attachment_results": [
+ {
+ "display_name": "photo.png",
+ "mime_type": "image/png",
+ "transport": "inline",
+ "text": "识别出的文字",
+ "text_excerpt": "识别出的文字",
+ }
+ ],
+ }
+ },
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ listed = await client.post(
+ "/agentengine/api/v1/ListSessions",
+ json={"AgentId": "demo-agent", "UserId": "user-1"},
+ )
+ fetched = await client.post(
+ "/agentengine/api/v1/GetSession",
+ json={"SessionId": session.id},
+ )
+
+ assert listed.status_code == 200
+ assert fetched.status_code == 200
+ for payload in (
+ listed.json()["Data"]["Sessions"][0],
+ fetched.json()["Data"]["Session"],
+ ):
+ state_context = payload["State"]["__ksadk_attachment_context__"]
+ attachment = state_context["attachments"][0]
+ assert attachment == {
+ "display_name": "photo.png",
+ "mime_type": "image/png",
+ "transport": "inline",
+ "size_bytes": 11,
+ }
+ assert "data" not in json.dumps(state_context, ensure_ascii=False)
+ assert state_context["attachment_results"][0]["text_excerpt"] == "识别出的文字"
+ assert "text" not in state_context["attachment_results"][0]
+
+
+@pytest.mark.asyncio
+async def test_local_feedback_actions_upsert_get_and_delete(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ session = await service.create_session(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id="sess-local-feedback",
+ )
+ assistant_event = await service.append_event(
+ session.id,
+ SessionEvent.from_dict(
+ {
+ "author": "demo-agent",
+ "event_type": "assistant_message",
+ "content": {"role": "model", "parts": [{"text": "assistant says hi"}]},
+ "metadata": {
+ "response_id": "resp_local_feedback",
+ "trace_id": "trace-local",
+ "root_span_id": "span-local",
+ },
+ },
+ session_id=session.id,
+ ),
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ created = await client.post(
+ "/agentengine/api/v1/UpsertResponseFeedback",
+ json={
+ "AgentId": "demo-agent",
+ "SessionId": session.id,
+ "ResponseId": "resp_local_feedback",
+ "EventId": assistant_event.id,
+ "Rating": "down",
+ "Comment": "不够具体",
+ },
+ )
+ fetched = await client.post(
+ "/agentengine/api/v1/GetResponseFeedback",
+ json={
+ "AgentId": "demo-agent",
+ "SessionId": session.id,
+ "ResponseId": "resp_local_feedback",
+ },
+ )
+ deleted = await client.post(
+ "/agentengine/api/v1/DeleteResponseFeedback",
+ json={
+ "AgentId": "demo-agent",
+ "SessionId": session.id,
+ "ResponseId": "resp_local_feedback",
+ },
+ )
+ fetched_after_delete = await client.post(
+ "/agentengine/api/v1/GetResponseFeedback",
+ json={
+ "AgentId": "demo-agent",
+ "SessionId": session.id,
+ "ResponseId": "resp_local_feedback",
+ },
+ )
+
+ assert created.status_code == 200
+ feedback = created.json()["Data"]["Feedback"]
+ assert feedback["AgentId"] == "demo-agent"
+ assert feedback["SessionId"] == session.id
+ assert feedback["ResponseId"] == "resp_local_feedback"
+ assert feedback["EventId"] == assistant_event.id
+ assert feedback["Rating"] == "down"
+ assert feedback["Comment"] == "不够具体"
+ assert feedback["TraceId"] == "trace-local"
+ assert feedback["RootSpanId"] == "span-local"
+
+ assert fetched.status_code == 200
+ assert fetched.json()["Data"]["Feedback"]["Rating"] == "down"
+ assert deleted.status_code == 200
+ assert deleted.json()["Data"] == {"Deleted": True}
+ assert fetched_after_delete.status_code == 200
+ assert fetched_after_delete.json()["Data"]["Feedback"] is None
+
+
+@pytest.mark.asyncio
+async def test_run_sse_stream_emits_authoritative_final_event_when_output_overrides_partials(
+ monkeypatch,
+):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _OverrideStreamingRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId=None,
+ newMessage={"role": "user", "parts": [{"text": "hello"}]},
+ streaming=True,
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ payloads = _sse_payloads(response.text)
+ assert [payload["content"]["parts"][0]["text"] for payload in payloads] == [
+ "hel",
+ "lo",
+ "goodbye",
+ ]
+ assert payloads[0]["partial"] is True
+ assert payloads[1]["partial"] is True
+ assert "partial" not in payloads[2]
+
+ session_id = payloads[0]["sessionId"]
+ events = await service.get_events(session_id)
+ assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
+ assert [event.event_type for event in events] == [
+ "user_message",
+ "run_status",
+ "assistant_message",
+ "run_status",
+ ]
+ assert events[-2].content["parts"][0]["text"] == "goodbye"
+
+
+@pytest.mark.asyncio
+async def test_run_sse_stream_emits_compaction_status_events(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
+ model_context_module = importlib.import_module("ksadk.conversations.model_context")
+ service = InMemorySessionService()
+ runner = _OverrideStreamingRunner()
+ session = await service.create_session(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id="session-with-history",
+ )
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+ monkeypatch.setattr(conversation_runtime, "AUTOCOMPACT_KEEP_TAIL_GROUPS", 1)
+ monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 30)
+ monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0)
+ monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
+ monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 2)
+
+ for turn_index in range(2):
+ invocation_id = f"seed-{turn_index}"
+ seed_text = f"历史消息 {turn_index} " + ("很长 " * 12)
+ await conversation_runtime.append_conversation_event(
+ session_id=session.id,
+ author="user",
+ role="user",
+ text=seed_text,
+ invocation_id=invocation_id,
+ event_type="user_message",
+ session_service_provider=lambda: service,
+ metadata={"agent_input": seed_text},
+ )
+ await conversation_runtime.append_conversation_event(
+ session_id=session.id,
+ author="demo-agent",
+ role="model",
+ text=f"历史回复 {turn_index} " + ("继续 " * 12),
+ invocation_id=invocation_id,
+ event_type="assistant_message",
+ session_service_provider=lambda: service,
+ )
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId=session.id,
+ newMessage={"role": "user", "parts": [{"text": "请继续基于历史回答"}]},
+ streaming=True,
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ events = _sse_events(response.text)
+ event_names = [event_name for event_name, _ in events]
+ assert event_names[:2] == [
+ "response.compaction.start",
+ "response.compaction.done",
+ ]
+ assert event_names.count("message") >= 2
+
+ persisted_events = await service.get_events(session.id)
+ assert [event.event_type for event in persisted_events] == [
+ "user_message",
+ "assistant_message",
+ "user_message",
+ "assistant_message",
+ "user_message",
+ "compaction_boundary",
+ "context_checkpoint",
+ "run_status",
+ "assistant_message",
+ "run_status",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_run_sse_stream_completes_and_persists_reasoning_when_no_text_deltas(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _ThinkingOnlyFinalRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId="sess-run-sse-thinking",
+ newMessage={"role": "user", "parts": [{"text": "hello"}]},
+ streaming=True,
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ events = await service.get_events("sess-run-sse-thinking")
+ assert [event.event_type for event in events] == [
+ "user_message",
+ "run_status",
+ "reasoning",
+ "assistant_message",
+ "run_status",
+ ]
+ assert events[2].content["parts"][0]["text"] == "先想一下"
+ assert events[-2].content["parts"][0]["text"] == "final answer"
+ assert events[-1].content["status"] == "completed"
+
+
+@pytest.mark.asyncio
+async def test_run_sse_prepares_runner_model_and_forwards_model_to_invoke(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _ModelAwareRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/run_sse",
+ json=AgentRunRequest(
+ appName="demo-agent",
+ userId="user-1",
+ sessionId=None,
+ newMessage={"role": "user", "parts": [{"text": "hello"}]},
+ streaming=False,
+ model="gpt-4o",
+ ).model_dump(),
+ )
+
+ assert response.status_code == 200
+ assert runner.prepared_models == ["gpt-4o"]
+ assert runner.calls[-1]["model"] == "gpt-4o"
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_forwards_model_to_runner(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _ModelAwareRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "hello"}],
+ "stream": False,
+ "model": "glm-5.1",
+ },
+ )
+
+ assert response.status_code == 200
+ assert runner.prepared_models == ["glm-5.1"]
+ assert runner.calls[-1]["model"] == "glm-5.1"
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_converts_chat_content_blocks_to_runner_responses_input(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ image_url = "data:image/png;base64,aW1hZ2U="
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "看图"},
+ {"type": "image_url", "image_url": {"url": image_url}},
+ ],
+ }
+ ],
+ "stream": False,
+ "model": "gpt-4o",
+ },
+ )
+
+ payload = response.json()
+ assert response.status_code == 200
+ assert payload["object"] == "chat.completion"
+ assert payload["choices"][0]["message"]["role"] == "assistant"
+ assert runner.calls[-1]["input_content"] == [
+ {"type": "input_text", "text": "看图"},
+ {"type": "input_image", "image_url": image_url},
+ ]
+ assert runner.calls[-1]["input_messages"] == [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "看图"},
+ {"type": "input_image", "image_url": image_url},
+ ],
+ }
+ ]
+ assert runner.calls[-1]["input_parts"] == [
+ {"text": "看图"},
+ {
+ "inlineData": {
+ "data": "aW1hZ2U=",
+ "mimeType": "image/png",
+ "displayName": "uploaded_image",
+ }
+ },
+ ]
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_non_stream_preserves_response_feedback_metadata(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ runner = _ModelAwareRunner()
+
+ async def _fake_invoke_conversation_once(**kwargs):
+ return "sess-trace", {
+ "output_text": "assistant says hi",
+ "metadata": {
+ "trace_id": "08c19ddddce0b1ddd29407dc637e1c89",
+ "root_span_id": "74cc406c8e9ded4a",
+ },
+ }
+
+ monkeypatch.setattr(
+ server_app_module.conversation,
+ "invoke_conversation_once",
+ _fake_invoke_conversation_once,
+ )
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "hello"}],
+ "stream": False,
+ "model": "glm-5.1",
+ },
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["metadata"] == {
+ "trace_id": "08c19ddddce0b1ddd29407dc637e1c89",
+ "root_span_id": "74cc406c8e9ded4a",
+ }
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_passes_attachment_results_to_runner(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii")
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"text": "请分析附件"},
+ {
+ "inlineData": {
+ "displayName": "resume.txt",
+ "mimeType": "text/plain",
+ "data": attachment_b64,
+ }
+ },
+ ],
+ }
+ ],
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ assert runner.calls[-1]["attachment_results"] == [
+ {
+ "display_name": "resume.txt",
+ "mime_type": "text/plain",
+ "transport": "inline",
+ "file_uri": "",
+ "size_bytes": len("候选人简历内容".encode("utf-8")),
+ "kind": "text",
+ "status": "ok",
+ "warnings": [],
+ "extraction_method": "text_decode",
+ "text_excerpt": "候选人简历内容",
+ "text": "候选人简历内容",
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_chat_completions_reuses_prior_attachment_results_on_follow_up_turn(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii")
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ first_response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"text": "请分析附件"},
+ {
+ "inlineData": {
+ "displayName": "resume.txt",
+ "mimeType": "text/plain",
+ "data": attachment_b64,
+ }
+ },
+ ],
+ }
+ ],
+ "stream": False,
+ },
+ )
+ first_payload = first_response.json()
+ session_id = first_payload["session_id"]
+
+ second_response = await client.post(
+ "/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "继续分析"}],
+ "session_id": session_id,
+ "stream": False,
+ },
+ )
+
+ assert first_response.status_code == 200
+ assert second_response.status_code == 200
+ assert runner.calls[-1]["attachment_results"] == [
+ {
+ "display_name": "resume.txt",
+ "mime_type": "text/plain",
+ "transport": "inline",
+ "size_bytes": len("候选人简历内容".encode("utf-8")),
+ "kind": "text",
+ "status": "ok",
+ "warnings": [],
+ "extraction_method": "text_decode",
+ "text_excerpt": "候选人简历内容",
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_list_agent_models_action_normalizes_default_metadata(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ real_async_client = httpx.AsyncClient
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_API_KEY", "secret-key")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setattr(
+ "httpx.AsyncClient",
+ lambda *args, **kwargs: _ExternalModelsAsyncClient(
+ *args,
+ payload={"data": [{"id": "glm-5.1"}]},
+ **kwargs,
+ ),
+ )
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/ListAgentModels",
+ json={"AgentId": "demo-agent"},
+ )
+
+ assert response.status_code == 200
+ payload = response.json()["Data"]
+ assert payload["Current"] == "glm-5.1"
+ assert payload["Models"] == [
+ {
+ "id": "glm-5.1",
+ "display_name": "glm-5.1",
+ "context_window_tokens": 200000,
+ "max_output_tokens": 32000,
+ "auto_compact_threshold_tokens": 167000,
+ "auto_compact_threshold_percentage": 84,
+ "capabilities": {
+ "function_calling": True,
+ "structured_output": True,
+ "context_caching": True,
+ "multimodal_input_image": False,
+ "multimodal_input_video": False,
+ "multimodal_input_file": False,
+ },
+ "limits": {
+ "context_window_tokens": 200000,
+ "max_input_tokens": 200000,
+ "max_output_tokens": 32000,
+ "max_reasoning_tokens": 32000,
+ "rpm": 500,
+ "tpm": 1000000,
+ },
+ "pricing": {
+ "online_input_per_million": 4.0,
+ "online_output_per_million": 18.0,
+ "batch_input_per_million": 2.0,
+ "batch_output_per_million": 9.0,
+ "online_cache_hit_input_per_million": 1.0,
+ "batch_cache_hit_input_per_million": 1.0,
+ },
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_list_agent_models_action_preserves_upstream_fields_and_normalizes_aliases(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ real_async_client = httpx.AsyncClient
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "kimi-k2.6")
+ monkeypatch.setattr(
+ "httpx.AsyncClient",
+ lambda *args, **kwargs: _ExternalModelsAsyncClient(
+ *args,
+ payload={
+ "data": [
+ {
+ "id": "kimi-k2.6",
+ "owned_by": "ksyun",
+ "context_length": 131072,
+ "max_tokens": 4096,
+ }
+ ]
+ },
+ **kwargs,
+ ),
+ )
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/ListAgentModels",
+ json={"AgentId": "demo-agent"},
+ )
+
+ assert response.status_code == 200
+ item = response.json()["Data"]["Models"][0]
+ assert item["id"] == "kimi-k2.6"
+ assert item["owned_by"] == "ksyun"
+ assert item["context_length"] == 131072
+ assert item["max_tokens"] == 4096
+ assert item["context_window_tokens"] == 131072
+ assert item["max_output_tokens"] == 4096
+ assert item["limits"]["context_window_tokens"] == 131072
+ assert item["limits"]["max_output_tokens"] == 4096
+
+
+@pytest.mark.asyncio
+async def test_list_agent_models_action_normalizes_kspmas_string_token_limits(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ real_async_client = httpx.AsyncClient
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ monkeypatch.setattr(
+ "httpx.AsyncClient",
+ lambda *args, **kwargs: _ExternalModelsAsyncClient(
+ *args,
+ payload={
+ "data": [
+ {
+ "id": "glm-5.1",
+ "context_length": "200k",
+ "max_completion_tokens": "128k",
+ "architecture": {
+ "input_modalities": ["文字"],
+ "output_modalities": ["文字"],
+ },
+ "pricing": {
+ "prompt": "6",
+ "completion": "24",
+ },
+ },
+ {
+ "id": "deepseek-v3.2",
+ "context_length": "128",
+ "max_completion_tokens": "32",
+ },
+ ]
+ },
+ **kwargs,
+ ),
+ )
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/ListAgentModels",
+ json={"AgentId": "demo-agent"},
+ )
+
+ assert response.status_code == 200
+ items = {item["id"]: item for item in response.json()["Data"]["Models"]}
+ assert items["glm-5.1"]["context_window_tokens"] == 200000
+ assert items["glm-5.1"]["max_output_tokens"] == 128000
+ assert items["glm-5.1"]["limits"]["context_window_tokens"] == 200000
+ assert items["glm-5.1"]["limits"]["max_output_tokens"] == 128000
+ assert items["glm-5.1"]["auto_compact_threshold_tokens"] == 167000
+ assert items["glm-5.1"]["architecture"]["input_modalities"] == ["文字"]
+ assert items["glm-5.1"]["capabilities"]["multimodal_input_image"] is False
+ assert items["glm-5.1"]["pricing"]["prompt"] == "6"
+ assert items["deepseek-v3.2"]["context_window_tokens"] == 128000
+ assert items["deepseek-v3.2"]["max_output_tokens"] == 32000
+ assert items["deepseek-v3.2"]["auto_compact_threshold_tokens"] == 95000
+
+
+@pytest.mark.asyncio
+async def test_list_agent_models_action_without_api_base_returns_default_metadata(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_BASE", raising=False)
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/ListAgentModels",
+ json={"AgentId": "demo-agent"},
+ )
+
+ assert response.status_code == 200
+ payload = response.json()["Data"]
+ assert payload["Current"] == "glm-5.1"
+ assert [item["id"] for item in payload["Models"]] == ["glm-5.1"]
+ assert payload["Models"][0]["context_window_tokens"] == 200000
+ assert payload["Models"][0]["limits"]["max_output_tokens"] == 32000
+
+
+@pytest.mark.asyncio
+async def test_openai_models_route_exposes_current_catalog(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_BASE", raising=False)
+ monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
+ transport = httpx.ASGITransport(app=server_app_module.app)
+
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.get("/v1/models")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["object"] == "list"
+ assert payload["current"] == "glm-5.1"
+ assert [item["id"] for item in payload["data"]] == ["glm-5.1"]
+
+
+@pytest.mark.asyncio
+async def test_responses_fetches_remote_model_metadata_and_passes_to_runner(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ real_async_client = httpx.AsyncClient
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "runner", runner)
+ monkeypatch.setattr(server_app_module, "_runner_loaded", True)
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
+ monkeypatch.setenv("OPENAI_API_KEY", "secret-key")
+ monkeypatch.setattr(
+ "httpx.AsyncClient",
+ lambda *args, **kwargs: _ExternalModelsAsyncClient(
+ *args,
+ payload={
+ "data": [
+ {
+ "id": "kimi-k2.6",
+ "architecture": {
+ "input_modalities": ["文字", "图片", "视频"],
+ "output_modalities": ["文字"],
+ },
+ }
+ ]
+ },
+ **kwargs,
+ ),
+ )
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "model": "kimi-k2.6",
+ "input": "请分析图片",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ assert runner.calls[0]["model_metadata"]["id"] == "kimi-k2.6"
+ assert runner.calls[0]["model_metadata"]["architecture"]["input_modalities"] == ["文字", "图片", "视频"]
+ assert runner.calls[0]["model_metadata"]["capabilities"]["multimodal_input_image"] is True
+
+
+@pytest.mark.asyncio
+async def test_responses_uses_official_conversation_as_runtime_session(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": "conv-a",
+ "safety_identifier": "user-a",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["session_id"] == "conv-a"
+ session = await service.get_session("conv-a")
+ assert session is not None
+ assert session.user_id == "user-a"
+ assert runner.calls[-1]["session_id"] == "conv-a"
+ assert runner.calls[-1]["platform_context"]["user_id"] == "user-a"
+
+
+@pytest.mark.asyncio
+async def test_responses_accepts_official_conversation_object(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": {"id": "conv-object"},
+ "safety_identifier": "user-object",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json()["session_id"] == "conv-object"
+ session = await service.get_session("conv-object")
+ assert session is not None
+ assert session.user_id == "user-object"
+
+
+@pytest.mark.asyncio
+async def test_responses_uses_deprecated_user_when_safety_identifier_missing(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": "conv-user",
+ "user": "deprecated-user",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ session = await service.get_session("conv-user")
+ assert session is not None
+ assert session.user_id == "deprecated-user"
+
+
+@pytest.mark.asyncio
+async def test_responses_rejects_conflicting_conversation_and_legacy_session_id(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": "conv-a",
+ "session_id": "legacy-b",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 400
+ assert "conversation" in response.text
+ assert "session_id" in response.text
+
+
+@pytest.mark.asyncio
+async def test_responses_rejects_conversation_with_previous_response_id(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": "conv-a",
+ "previous_response_id": "resp_previous",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 400
+ assert "conversation" in response.text
+ assert "previous_response_id" in response.text
+
+
+@pytest.mark.asyncio
+async def test_responses_legacy_session_id_still_works(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "session_id": "legacy-session",
+ "stream": False,
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json()["session_id"] == "legacy-session"
+ session = await service.get_session("legacy-session")
+ assert session is not None
+ assert session.user_id == "user"
+
+
+@pytest.mark.asyncio
+async def test_responses_events_are_visible_through_runtime_local_list_session_events(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ run_response = await client.post(
+ "/v1/responses",
+ json={
+ "input": "hello",
+ "conversation": "conv-events",
+ "safety_identifier": "user-events",
+ "stream": False,
+ },
+ )
+ events_response = await client.post(
+ "/agentengine/api/v1/ListSessionEvents",
+ json={"SessionId": "conv-events"},
+ )
+
+ assert run_response.status_code == 200
+ assert events_response.status_code == 200
+ events = events_response.json()["Data"]["Events"]
+ message_events = [event for event in events if event["EventType"] in {"user_message", "assistant_message"}]
+ assert [event["Author"] for event in message_events] == ["user", "demo-agent"]
+ assert message_events[0]["Content"]["parts"][0]["text"] == "hello"
+ assert message_events[1]["Content"]["parts"][0]["text"] == "assistant says hi"
+
+
+@pytest.mark.asyncio
+async def test_run_agent_action_passes_model_options_to_runner(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _DummyRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.post(
+ "/agentengine/api/v1/RunAgent",
+ json={
+ "AgentId": "demo-agent",
+ "Messages": [{"role": "user", "content": "hello"}],
+ "Stream": False,
+ "Model": "glm-5.1",
+ "ModelOptions": {"thinking": {"type": "disabled"}},
+ },
+ )
+
+ assert response.status_code == 200
+ assert runner.calls[-1]["model_options"] == {
+ "thinking": {"type": "disabled"},
+ "reasoning": {"effort": "none"},
+ "max_reasoning_tokens": 0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_subscribe_run_events_streams_events_appended_after_subscription(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(_DummyRunner())
+
+ session = await service.create_session(
+ agent_id="demo-agent",
+ user_id="user-1",
+ session_id="sess-subscribe",
+ )
+ in_progress = await service.append_event(
+ session.id,
+ SessionEvent.from_dict(
+ {
+ "author": "demo-agent",
+ "eventType": "run_status",
+ "invocationId": "inv-live",
+ "content": {"status": "in_progress"},
+ },
+ session_id=session.id,
+ ),
+ )
+
+ async def append_later():
+ await asyncio.sleep(0.02)
+ await service.append_event(
+ session.id,
+ SessionEvent.from_dict(
+ {
+ "author": "demo-agent",
+ "eventType": "assistant_message",
+ "invocationId": "inv-live",
+ "content": {"role": "model", "parts": [{"text": "hello"}]},
+ },
+ session_id=session.id,
+ ),
+ )
+ await service.append_event(
+ session.id,
+ SessionEvent.from_dict(
+ {
+ "author": "demo-agent",
+ "eventType": "run_status",
+ "invocationId": "inv-live",
+ "content": {"status": "completed"},
+ },
+ session_id=session.id,
+ ),
+ )
+
+ task = asyncio.create_task(append_later())
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ response = await client.get(
+ "/agentengine/api/v1/SubscribeRunEvents",
+ params={
+ "SessionId": session.id,
+ "InvocationId": "inv-live",
+ "AfterSeqId": str(in_progress.seq_id),
+ },
+ )
+ await task
+
+ assert response.status_code == 200
+ payloads = [
+ json.loads(line.removeprefix("data: "))
+ for line in response.text.splitlines()
+ if line.startswith("data: ") and line.strip() != "data: [DONE]"
+ ]
+ assert [payload["EventType"] for payload in payloads] == [
+ "assistant_message",
+ "run_status",
+ ]
+ assert payloads[0]["Content"]["parts"][0]["text"] == "hello"
+ assert payloads[-1]["Content"]["status"] == "completed"
+
+
+@pytest.mark.asyncio
+async def test_run_agent_stream_continues_after_client_disconnect(monkeypatch):
+ server_app_module = importlib.import_module("ksadk.server.app")
+ service = InMemorySessionService()
+ runner = _SlowStreamingRunner()
+
+ monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
+ server_app_module.set_runner(runner)
+
+ transport = httpx.ASGITransport(app=server_app_module.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
+ async with client.stream(
+ "POST",
+ "/agentengine/api/v1/RunAgent",
+ json={
+ "AgentId": "demo-agent",
+ "SessionId": "sess-detached-run",
+ "Messages": [{"role": "user", "content": "hello"}],
+ "Stream": True,
+ "ApiFormat": "responses",
+ },
+ ) as response:
+ assert response.status_code == 200
+ async for line in response.aiter_lines():
+ if line.startswith("data: ") and "response.created" in line:
+ break
+
+ for _ in range(20):
+ events = await service.get_events("sess-detached-run")
+ if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "completed":
+ break
+ await asyncio.sleep(0.02)
+
+ events = await service.get_events("sess-detached-run")
+ assert [event.event_type for event in events] == [
+ "user_message",
+ "run_status",
+ "assistant_message",
+ "run_status",
+ ]
+ assert events[-2].content["parts"][0]["text"] == "hello"
+ assert events[-1].content["status"] == "completed"
diff --git a/tests/test_tool_gateway.py b/tests/test_tool_gateway.py
index f7c2b29..a5883b3 100644
--- a/tests/test_tool_gateway.py
+++ b/tests/test_tool_gateway.py
@@ -1,91 +1,72 @@
from __future__ import annotations
+from ksadk.tools.gateway import (
+ ToolGateway,
+ ToolPolicy,
+ approval_interrupt_info_from_result,
+ default_tool_gateway,
+ tool_policy_requires_approval,
+)
-def test_tool_gateway_allows_safe_tools_by_default(monkeypatch):
- from ksadk.tools.gateway import ToolGateway, ToolPolicy
- monkeypatch.delenv("KSADK_TOOL_APPROVAL_MODE", raising=False)
- gateway = ToolGateway({"demo_tool": ToolPolicy(risk_level="low")})
+def test_tool_gateway_imports_public_api():
+ gateway = default_tool_gateway({"delete_file": ToolPolicy(risk_level="high")})
- result = gateway.invoke("demo_tool", lambda value: {"ok": True, "value": value}, "hello")
+ assert isinstance(gateway, ToolGateway)
- assert result == {"ok": True, "value": "hello"}
+def test_tool_policy_requires_approval_only_in_strict_mode():
+ policy = ToolPolicy(risk_level="high")
-def test_tool_gateway_returns_approval_request_for_risky_tools_in_strict_mode(monkeypatch):
- from ksadk.tools.gateway import ToolGateway, ToolPolicy
+ assert tool_policy_requires_approval(policy, approval_mode="off") is False
+ assert tool_policy_requires_approval(policy, approval_mode="permissive") is False
+ assert tool_policy_requires_approval(policy, approval_mode="strict") is True
- called = False
-
- def dangerous_tool():
- nonlocal called
- called = True
- return {"ok": True}
+def test_tool_gateway_returns_approval_request_in_strict_mode(monkeypatch):
monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- gateway = ToolGateway(
- {
- "dangerous_tool": ToolPolicy(
- risk_level="high",
- side_effects=("workspace_write",),
- )
- }
- )
-
- result = gateway.invoke("dangerous_tool", dangerous_tool)
-
- assert called is False
- assert result["ok"] is False
+ gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium", side_effects=("workspace_write",))})
+
+ result = gateway.invoke("write_file", lambda: {"ok": True})
+
assert result["type"] == "approval_required"
assert result["approval_required"] is True
- assert result["approval_request"]["tool_name"] == "dangerous_tool"
- assert result["approval_request"]["risk_level"] == "high"
+ assert result["approval_request"]["tool_name"] == "write_file"
+ assert result["approval_request"]["risk_level"] == "medium"
assert result["approval_request"]["side_effects"] == ["workspace_write"]
-def test_tool_gateway_approved_request_runs_risky_tool(monkeypatch):
- from ksadk.tools.gateway import ToolGateway, ToolPolicy
-
+def test_tool_gateway_runs_approved_call_in_strict_mode(monkeypatch):
monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- gateway = ToolGateway({"dangerous_tool": ToolPolicy(risk_level="high")})
-
- result = gateway.invoke(
- "dangerous_tool",
- lambda: {"ok": True, "ran": True},
- approval={"approved": True},
- )
-
- assert result == {"ok": True, "ran": True}
+ gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium")})
+ assert gateway.invoke("write_file", lambda value: {"ok": True, "value": value}, 3, approval={"approved": True}) == {
+ "ok": True,
+ "value": 3,
+ }
-def test_approval_interrupt_info_from_gateway_result():
- from ksadk.tools.gateway import approval_interrupt_info_from_result
+def test_approval_interrupt_info_from_result_normalizes_payload():
result = {
- "ok": False,
"type": "approval_required",
"approval_request": {
"id": "appr_123",
- "tool_name": "write_workspace_file",
+ "tool_name": "write_file",
+ "tool_args": {"path": "demo.txt"},
"risk_level": "medium",
"side_effects": ["workspace_write"],
},
}
- interrupt = approval_interrupt_info_from_result(
- result,
- fallback_tool_name="fallback",
- tool_args={"path": "notes.txt"},
- run_id="run-1",
- )
+ interrupt = approval_interrupt_info_from_result(result, fallback_tool_name="fallback", run_id="run_1")
assert interrupt == {
"id": "appr_123",
"approval_request_id": "appr_123",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt"},
+ "tool_name": "write_file",
+ "arguments": {"path": "demo.txt"},
"risk_level": "medium",
"side_effects": ["workspace_write"],
- "run_id": "run-1",
"server_label": "ksadk",
+ "run_id": "run_1",
}
diff --git a/tests/test_unified_agent_ui_local.py b/tests/test_unified_agent_ui_local.py
index e122384..1d1dd94 100644
--- a/tests/test_unified_agent_ui_local.py
+++ b/tests/test_unified_agent_ui_local.py
@@ -283,6 +283,26 @@ async def test_get_agent_ui_bootstrap_matches_local_shape_parity(monkeypatch):
"enabled": True,
"boundary": "workspace_root",
},
+ {
+ "name": "edit_workspace_file",
+ "group": "workspace",
+ "description": "Replace an exact text snippet inside a UTF-8 workspace file.",
+ "risk_level": "medium",
+ "requires_approval": False,
+ "side_effects": ["workspace_edit"],
+ "enabled": True,
+ "boundary": "workspace_root",
+ },
+ {
+ "name": "lint_workspace_file",
+ "group": "workspace",
+ "description": "Run lightweight built-in lint checks for a UTF-8 workspace text file.",
+ "risk_level": "low",
+ "requires_approval": False,
+ "side_effects": [],
+ "enabled": True,
+ "boundary": "workspace_root",
+ },
{
"name": "search_workspace_files",
"group": "workspace",
@@ -312,6 +332,39 @@ async def test_get_agent_ui_bootstrap_matches_local_shape_parity(monkeypatch):
"side_effects": [],
"enabled": True,
},
+ {
+ "name": "search_knowledge_base",
+ "group": "platform",
+ "description": (
+ "搜索知识库获取相关信息。\n\n"
+ "当需要查找专业知识、文档内容或特定领域信息时使用此工具。\n"
+ "会自动从已配置的金山云知识库中检索最相关的内容。\n\n"
+ "Args:\n"
+ " query: 检索关键词或问题"
+ ),
+ "risk_level": "low",
+ "requires_approval": False,
+ "side_effects": [],
+ "enabled": True,
+ },
+ {
+ "name": "load_memory",
+ "group": "platform",
+ "description": "检索当前用户的长期记忆。",
+ "risk_level": "low",
+ "requires_approval": False,
+ "side_effects": [],
+ "enabled": True,
+ },
+ {
+ "name": "save_memory",
+ "group": "platform",
+ "description": "保存一条长期记忆。",
+ "risk_level": "low",
+ "requires_approval": False,
+ "side_effects": [],
+ "enabled": True,
+ },
{
"name": "sandbox_status",
"group": "sandbox",
@@ -567,6 +620,7 @@ async def test_run_agent_action_streaming_responses_uses_responses_lifecycle(mon
assert "event: response.tool_result" not in lines
assert runner.invocations[-1]["model"] == "glm-5.1"
assert runner.invocations[-1]["session_id"] == "sess-runagent-responses"
+ assert runner.invocations[-1]["responses_conversation"] is True
assert await service.get_session("sess-runagent-responses") is not None
stored_events = await service.get_events("sess-runagent-responses")
assistant_events = [event for event in stored_events if event.event_type == "assistant_message"]
@@ -1101,6 +1155,7 @@ async def test_responses_endpoint_passes_full_request_history_to_runner(monkeypa
assert response.status_code == 200
assert runner.invocations[-1]["input"] == "用go"
+ assert "responses_conversation" not in runner.invocations[-1]
assert runner.invocations[-1]["history"] == [
{"role": "user", "content": "写一个python快排的示例"},
{"role": "model", "content": "这是 Python 快速排序示例。"},
@@ -1136,6 +1191,7 @@ async def test_responses_endpoint_non_streaming_supports_instructions_and_metada
assert payload["output_text"] == "assistant says hi"
assert payload["session_id"]
assert runner.invocations[-1]["instructions"] == "只用中文回答"
+ assert "responses_conversation" not in runner.invocations[-1]
events = await service.get_events(payload["session_id"])
user_event = next(event for event in events if event.event_type == "user_message")
@@ -1887,22 +1943,15 @@ async def test_static_routes_serve_unified_agent_ui_shell(monkeypatch):
assert "overflow" in css_response.text
-def _read_web_ui_source_or_skip(path: str) -> str:
- source_path = Path(path)
- if not source_path.exists():
- pytest.skip("ksadk-web is the canonical UI source; embedded web-ui source is optional")
- return source_path.read_text(encoding="utf-8")
-
-
def test_web_ui_source_uses_title_and_summary_in_sidebar():
- sidebar_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx"
+ sidebar_source = Path("ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx").read_text(
+ encoding="utf-8"
)
- session_helpers_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/utils/session-helpers.ts"
+ session_helpers_source = Path("ksadk/server/web-ui/src/utils/session-helpers.ts").read_text(
+ encoding="utf-8"
)
- session_list_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/utils/session-list.js"
+ session_list_source = Path("ksadk/server/web-ui/src/utils/session-list.js").read_text(
+ encoding="utf-8"
)
assert "session.Title" in session_helpers_source
assert "session?.Summary" in session_list_source
@@ -1910,19 +1959,27 @@ def test_web_ui_source_uses_title_and_summary_in_sidebar():
def test_web_ui_source_supports_clipboard_file_paste():
- composer_source = _read_web_ui_source_or_skip(
+ composer_source = Path(
"ksadk/server/web-ui/src/components/chat/ConnectedComposer.tsx"
+ ).read_text(encoding="utf-8")
+ attachment_source = Path("ksadk/server/web-ui/src/utils/attachment.ts").read_text(
+ encoding="utf-8"
)
- attachment_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/utils/attachment.ts")
assert "clipboardData.items" in attachment_source
assert "onPaste" in composer_source
assert "getAsFile" in attachment_source
def test_web_ui_source_prefers_responses_when_runtime_supports_it():
- bootstrap_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/hooks/useBootstrap.ts")
- layout_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/utils/layout-constants.ts")
- run_engine_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/core/run/engine.ts")
+ bootstrap_source = Path("ksadk/server/web-ui/src/hooks/useBootstrap.ts").read_text(
+ encoding="utf-8"
+ )
+ layout_source = Path("ksadk/server/web-ui/src/utils/layout-constants.ts").read_text(
+ encoding="utf-8"
+ )
+ run_engine_source = Path("ksadk/server/web-ui/src/core/run/engine.ts").read_text(
+ encoding="utf-8"
+ )
assert "setAgentFramework" in bootstrap_source
assert "if (apiFormats.includes('responses'))" in layout_source
assert "return 'responses'" in layout_source
@@ -1944,32 +2001,46 @@ def test_static_workbench_uses_openai_responses_content_for_inline_attachments()
def test_web_ui_run_engine_uses_responses_input_for_responses_protocol():
- run_engine_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/core/run/engine.ts")
+ run_engine_source = Path("ksadk/server/web-ui/src/core/run/engine.ts").read_text(
+ encoding="utf-8"
+ )
assert "body.ResponsesInput = [{ role: 'user', content: parts }]" in run_engine_source
assert "body.Messages = [{ role: 'user', content: parts }]" in run_engine_source
def test_web_ui_source_supports_workspace_panel_for_owner_access():
- source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/App.tsx")
- header_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/components/chat/ChatHeader.tsx"
+ source = Path("ksadk/server/web-ui/src/App.tsx").read_text(encoding="utf-8")
+ header_source = Path("ksadk/server/web-ui/src/components/chat/ChatHeader.tsx").read_text(
+ encoding="utf-8"
+ )
+ api_facade_source = Path("ksadk/server/web-ui/src/core/api/facade.ts").read_text(
+ encoding="utf-8"
)
- api_facade_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/core/api/facade.ts")
- bootstrap_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/hooks/useBootstrap.ts")
- layout_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/utils/layout-constants.ts")
- run_engine_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/core/run/engine.ts")
- workspace_api_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/api/workspace.ts")
- workspace_source = _read_web_ui_source_or_skip(
+ bootstrap_source = Path("ksadk/server/web-ui/src/hooks/useBootstrap.ts").read_text(
+ encoding="utf-8"
+ )
+ layout_source = Path("ksadk/server/web-ui/src/utils/layout-constants.ts").read_text(
+ encoding="utf-8"
+ )
+ run_engine_source = Path("ksadk/server/web-ui/src/core/run/engine.ts").read_text(
+ encoding="utf-8"
+ )
+ workspace_api_source = Path("ksadk/server/web-ui/src/api/workspace.ts").read_text(
+ encoding="utf-8"
+ )
+ workspace_source = Path(
"ksadk/server/web-ui/src/components/workspace/WorkspacePanel.tsx"
+ ).read_text(encoding="utf-8")
+ workspace_utils_source = Path("ksadk/server/web-ui/src/utils/workspace.js").read_text(
+ encoding="utf-8"
)
- workspace_utils_source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/utils/workspace.js")
- session_events_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/utils/session-events.js"
+ session_events_source = Path("ksadk/server/web-ui/src/utils/session-events.js").read_text(
+ encoding="utf-8"
)
- responses_stream_source = _read_web_ui_source_or_skip(
+ responses_stream_source = Path(
"ksadk/server/web-ui/src/utils/responses-stream.js"
- )
+ ).read_text(encoding="utf-8")
assert "WorkspaceFiles" in source
assert "canAccessWorkspaceFiles({ workspaceFiles, accessMode })" in source
assert "mode === 'owner' || mode === 'private'" in workspace_utils_source
@@ -1994,18 +2065,18 @@ def test_web_ui_source_supports_workspace_panel_for_owner_access():
def test_web_ui_source_supports_streaming_queue_and_refresh_pending_status():
- source = _read_web_ui_source_or_skip("ksadk/server/web-ui/src/App.tsx")
- connected_composer_source = _read_web_ui_source_or_skip(
+ source = Path("ksadk/server/web-ui/src/App.tsx").read_text(encoding="utf-8")
+ connected_composer_source = Path(
"ksadk/server/web-ui/src/components/chat/ConnectedComposer.tsx"
- )
- composer_source = _read_web_ui_source_or_skip(
+ ).read_text(encoding="utf-8")
+ composer_source = Path(
"ksadk/server/web-ui/src/components/chat/ChatComposer.tsx"
+ ).read_text(encoding="utf-8")
+ sidebar_source = Path("ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx").read_text(
+ encoding="utf-8"
)
- sidebar_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx"
- )
- session_events_source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/utils/session-events.js"
+ session_events_source = Path("ksadk/server/web-ui/src/utils/session-events.js").read_text(
+ encoding="utf-8"
)
assert "queuedDraftRef" in source
assert "queuedDrafts={queuedDrafts}" in connected_composer_source
@@ -2020,20 +2091,20 @@ def test_web_ui_source_supports_streaming_queue_and_refresh_pending_status():
def test_web_ui_source_threads_generation_controls_into_message_list():
- source = _read_web_ui_source_or_skip(
- "ksadk/server/web-ui/src/components/chat/ChatMessageList.tsx"
+ source = Path("ksadk/server/web-ui/src/components/chat/ChatMessageList.tsx").read_text(
+ encoding="utf-8"
)
assert "onStopGeneration," in source
assert "onCancelRemote," in source
def test_web_ui_source_uses_adaptive_image_preview_sizing():
- connected_message_list_source = _read_web_ui_source_or_skip(
+ connected_message_list_source = Path(
"ksadk/server/web-ui/src/components/chat/ConnectedMessageList.tsx"
- )
- preview_source = _read_web_ui_source_or_skip(
+ ).read_text(encoding="utf-8")
+ preview_source = Path(
"ksadk/server/web-ui/src/components/chat/AttachmentPreview.tsx"
- )
+ ).read_text(encoding="utf-8")
assert "naturalWidth" in preview_source
assert "naturalHeight" in preview_source
assert "setPreviewImageSize" in connected_message_list_source
diff --git a/uv.lock b/uv.lock
index e69f38f..9d458ec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2451,7 +2451,7 @@ wheels = [
[[package]]
name = "ksadk"
-version = "0.6.2"
+version = "0.6.3"
source = { editable = "." }
dependencies = [
{ name = "a2a-sdk" },