From d93513f989665ffabc243007a72c81d7eea483ee Mon Sep 17 00:00:00 2001 From: "Anqi (Anthony) Tang" Date: Sun, 30 Aug 2026 23:40:15 +0800 Subject: [PATCH] feat(lark-cli): add Feishu approval submission via official lark-cli Integrate larksuite/cli as a built-in harness capability (lark_cli) with a layered command policy (relaxed read / strict write whitelist / dynamic Risk-probe), device-code user login, in-conversation app provisioning, dry-run preview with confirmation gate and idempotent replay, plus the lark_approval_submit SOP card. Trusted-layer error messages redirect the model to the correct next action (auth scopes / auth qrcode cases). --- backend/app/config.py | 8 + backend/app/core/capability_manifest.py | 35 + backend/app/core/harness_agent.py | 76 + .../app/core/harness_capability_invoker.py | 49 +- backend/app/core/harness_v2_engine.py | 18 +- backend/app/core/task_request_compiler.py | 37 +- .../staffdeck_expanded_gallery_seed.json | 399 +++++ backend/app/lark_cli/__init__.py | 6 + backend/app/lark_cli/background.py | 214 +++ backend/app/lark_cli/policy.py | 572 ++++++++ backend/app/lark_cli/provision.py | 172 +++ backend/app/lark_cli/runner.py | 223 +++ backend/app/lark_cli/service.py | 712 +++++++++ backend/tests/test_harness_v2.py | 164 ++- backend/tests/test_lark_cli.py | 1277 +++++++++++++++++ backend/tests/test_staffdeck_seed.py | 17 +- docs/lark-cli-approval-sop.json | 179 +++ 17 files changed, 4143 insertions(+), 15 deletions(-) create mode 100644 backend/app/lark_cli/__init__.py create mode 100644 backend/app/lark_cli/background.py create mode 100644 backend/app/lark_cli/policy.py create mode 100644 backend/app/lark_cli/provision.py create mode 100644 backend/app/lark_cli/runner.py create mode 100644 backend/app/lark_cli/service.py create mode 100644 backend/tests/test_lark_cli.py create mode 100644 docs/lark-cli-approval-sop.json diff --git a/backend/app/config.py b/backend/app/config.py index bb9e0d628..7b7f4d51f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,14 @@ class Settings(BaseSettings): codex_a2a_workspace_root: str = "" codex_a2a_timeout_seconds: float = 1800.0 codex_a2a_token: str = "" + # 飞书官方 lark-cli 集成(app/lark_cli/)。开启即进入能力清单;应用凭据 + # 与用户登录都在对话中完成(config init / auth login 设备码),无需预置 + # 配置或飞书渠道绑定。 + lark_cli_enabled: bool = True + # 二进制供给见 lark_cli/provision.py:懒加载(首次调用时装,非启动时), + # 装到用户数据目录。关掉自动安装则必须自备二进制并指定 binary_path。 + lark_cli_auto_install: bool = True + lark_cli_binary_path: str = "" tool_base_url: str = "http://localhost:5173" cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" general_skill_runtime_python: str = "" diff --git a/backend/app/core/capability_manifest.py b/backend/app/core/capability_manifest.py index 1657d7ef3..406f05abf 100644 --- a/backend/app/core/capability_manifest.py +++ b/backend/app/core/capability_manifest.py @@ -43,6 +43,7 @@ "exec_command", "run_skill_script", "knowledge_search", + "lark_cli", } @@ -68,6 +69,12 @@ def build( unavailable: list[CapabilityDescriptor] = [] available.extend(_internal_capability_descriptors()) + lark_descriptor = _lark_cli_descriptor(self.db, tenant_id, agent_id) + if lark_descriptor is not None: + if lark_descriptor.available: + available.append(lark_descriptor) + else: + unavailable.append(lark_descriptor) ui_config = self.db.get(UIConfig, tenant_id) sandbox_enabled = bool(getattr(ui_config, "sandbox_enabled", False)) @@ -353,6 +360,34 @@ def _unavailable_explicit_refs( return unavailable +def _lark_cli_descriptor( + db: Session, tenant_id: str, agent_id: str | None +) -> CapabilityDescriptor | None: + """settings 未开启时返回 None(清单完全不出现,保持既有行为)。 + + 开启即视为可用:应用凭据除 settings / 渠道绑定外,还可在对话内通过 + ``config init`` 现场建立(含 ``--new`` 创建新应用),无法在编译清单时 + 预判缺失,缺凭据的具体指引由 service 层以可恢复错误给出。 + """ + + from app.config import get_settings + from app.lark_cli.service import LARK_CLI_DESCRIPTION, LARK_CLI_INPUT_SCHEMA + + del db, tenant_id, agent_id # 凭据可对话内建立后不再需要预检数据库。 + settings = get_settings() + if not settings.lark_cli_enabled: + return None + return CapabilityDescriptor( + capability_id="builtin.lark_cli", + name="lark_cli", + kind="internal", + description=LARK_CLI_DESCRIPTION, + input_schema=dict(LARK_CLI_INPUT_SCHEMA), + metadata={"provider": "builtin.lark_cli", "side_effect": "write"}, + available=True, + ) + + def _internal_capability_descriptors() -> list[CapabilityDescriptor]: return [ CapabilityDescriptor( diff --git a/backend/app/core/harness_agent.py b/backend/app/core/harness_agent.py index b24efd30c..c60a7baf5 100644 --- a/backend/app/core/harness_agent.py +++ b/backend/app/core/harness_agent.py @@ -79,6 +79,21 @@ def run( and str(checkpoint.get("step_id") or "") == current_step_id ) transcript = _dict_items(checkpoint.get("transcript")) if same_frame else [] + # 从暂停恢复时,把用户的最新回复显式写进内层对话记录:仅靠 + # requirement.source_user_message 字段太不显眼,实测模型会忽略它 + # 而重复上一轮的提问(真实案例:确认暂停点反复索要确认)。 + latest_user_message = str(requirement.source_user_message or "").strip() + if same_frame and transcript and latest_user_message: + previous_user_entry = next( + ( + str(item.get("content") or "") + for item in reversed(transcript) + if item.get("role") == "user" + ), + None, + ) + if previous_user_entry != latest_user_message: + transcript.append({"role": "user", "content": latest_user_message}) citations = _dict_items(checkpoint.get("citations")) if same_frame else [] evidence_results = ( _dict_items(checkpoint.get("evidence_results")) if same_frame else [] @@ -110,6 +125,8 @@ def run( # checkpoint made a later user turn inherit an obsolete failure even # after its inputs or external state had changed. non_retryable_action_signatures: set[str] = set() + # 强制能力从未尝试就 finish(failed) 只拦一次,避免与固执模型互相死锁。 + failed_finish_without_attempt_blocked = False allowed_names = requirement.capability_manifest.allowed_names() system_prompt = PROMPT_PATH.read_text(encoding="utf-8").strip() pending_actions: list[HarnessAction] = [] @@ -369,6 +386,65 @@ def finish(result: TaskExecutionResult) -> TaskExecutionResult: }, ) continue + # 真实案例:提交节点的模型带着上一步骤的工具报错直接 + # finish(failed),全程没在本节点尝试过强制能力。失败结论 + # 必须建立在真实尝试之上,先打回一次要求实际调用。 + if ( + action.status == "failed" + and missing_capabilities + and not failed_finish_without_attempt_blocked + ): + attempted = { + str(item.get("tool_name") or "") + for item in capability_results + if isinstance(item, dict) + } + unattempted = [ + name + for name in missing_capabilities + if not name.startswith("knowledge_search:") + and name not in attempted + ] + if unattempted: + failed_finish_without_attempt_blocked = True + transcript.extend( + [ + { + "role": "assistant", + "action": "finish", + "status": "failed", + }, + { + "role": "tool", + "tool_name": "harness_requirement_check", + "result": { + "success": False, + "error": { + "code": ( + "REQUIRED_CAPABILITY_NOT_ATTEMPTED" + ), + "message": ( + "当前 SOP 节点的强制能力在本节点内" + "尚未尝试调用,不能直接宣告失败:" + + "、".join(unattempted) + + "。请先实际调用,再依据其真实" + "结果决定完成或失败。" + ), + }, + }, + }, + ] + ) + if trace_sink: + trace_sink( + "harness_completion_blocked", + { + "iteration": iteration, + "reason": "required_capability_not_attempted", + "missing_capabilities": unattempted, + }, + ) + continue return finish(_finish_result( requirement, action, diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index fc982a1e6..98550c8f4 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -60,8 +60,8 @@ register_skill_script_tools, snapshot_harness_workspace, ) -from app.harness.execution_context import SANDBOX_WORKSPACE from app.harness.errors import HarnessExecutionError +from app.harness.execution_context import SANDBOX_WORKSPACE from app.harness.sandbox import parse_network_policy from app.knowledge.citations import knowledge_citations_from_results from app.knowledge.schema import KnowledgeSearchRequest @@ -318,6 +318,25 @@ def _logical_action_key( descriptor: CapabilityDescriptor, arguments: dict[str, Any], ) -> str | None: + if descriptor.kind == "internal" and descriptor.name == "lark_cli": + from app.lark_cli.policy import logical_write_signature + + signature = logical_write_signature(arguments) + if signature is None: + return None + canonical = json.dumps( + { + "tenant_id": self.tenant_id, + "task_frame_id": self.task_frame_id, + "step_id": self.active_step_id, + "tool_id": "builtin.lark_cli", + "signature": signature, + }, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() if descriptor.kind != "tool": return None tool = self.db.get(Tool, descriptor.capability_id) @@ -503,6 +522,19 @@ def _invoke_internal( return self._list_published_deliverables(arguments) if name == "read_published_deliverable": return self._read_published_deliverable(arguments) + if name == "lark_cli": + from app.lark_cli.service import invoke_lark_cli + + return invoke_lark_cli( + self.db, + tenant_id=self.tenant_id, + session=self.session, + task_frame_id=self.task_frame_id, + agent_id=self.agent_id, + arguments=arguments, + active_skill=self.active_skill, + active_step_id=self.active_step_id, + ) return _failure( "UNSUPPORTED_INTERNAL_CAPABILITY", "不支持的 Harness 内部能力。", @@ -1512,11 +1544,26 @@ def _audit_arguments(arguments: dict[str, Any]) -> dict[str, Any]: for token in ("content", "secret", "token", "password", "api_key") ): audited[str(key)] = "" + elif isinstance(value, list): + audited[str(key)] = _redact_secret_flag_values(value) else: audited[str(key)] = value return audited +def _redact_secret_flag_values(items: list[Any]) -> list[Any]: + """argv 风格列表里跟在敏感 flag(如 --app-secret)后的值不落审计记录。""" + + redacted = list(items) + for index, token in enumerate(redacted[:-1]): + if not isinstance(token, str) or not token.startswith("-"): + continue + lowered = token.lower() + if any(part in lowered for part in ("secret", "token", "password")): + redacted[index + 1] = "" + return redacted + + def _audit_result(result: dict[str, Any]) -> dict[str, Any]: audited = dict(result) data = audited.get("data") diff --git a/backend/app/core/harness_v2_engine.py b/backend/app/core/harness_v2_engine.py index 7f99513ae..97b540590 100644 --- a/backend/app/core/harness_v2_engine.py +++ b/backend/app/core/harness_v2_engine.py @@ -848,12 +848,15 @@ def _run_frame( ], attachment_descriptors, published_deliverables, + # 驱动本次执行的是当前这条用户消息。长驻 SOP 帧跨回合恢复 + # 时若改用创建该帧的老消息,会顶掉用户的最新回复(真实案例: + # 「提交」被首句需求顶掉,恢复的 agent 看不到而重复提问)。 + # 原始意图仍在 user_intent 与 slots 里,不丢。 source_user_message=( - request.message - if row.source_turn_id == self.user_message_id - else _source_user_message(self.db, row) + request.message.strip() or _source_user_message(self.db, row) ), out_of_scope_task_intents=_sibling_task_intents(self.db, row), + client_timezone=request.client_timezone, ) if ( self.slash_command @@ -1542,10 +1545,17 @@ def _defer_failed_step_after_completed_checkpoint( for summary in (checkpoint.task_summary.strip(), failure_summary) if summary ] + # 上一节点的回复可能带有"接下来将…"式的前瞻表述;后续步骤实际已 + # 暂停排队、要等下一条用户消息才继续,必须向用户说清(真实案例: + # 用户看到"将进入正式提交步骤"以为会自动提交,实际什么都没发生)。 + reply = checkpoint.reply_fragment.rstrip() + suffix = "(本轮执行到此暂停,剩余步骤已排队;回复任意消息即可继续。)" + if suffix not in reply: + reply = f"{reply}\n\n{suffix}" return result.model_copy( update={ "status": "action_budget", - "reply_fragment": checkpoint.reply_fragment, + "reply_fragment": reply, "next_step_id": None, "task_summary": ";".join(dict.fromkeys(summaries)), } diff --git a/backend/app/core/task_request_compiler.py b/backend/app/core/task_request_compiler.py index ae0550201..5d8dbbc09 100644 --- a/backend/app/core/task_request_compiler.py +++ b/backend/app/core/task_request_compiler.py @@ -1,13 +1,14 @@ from __future__ import annotations +from datetime import datetime from typing import Any, Literal +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel, Field from app.db.models import ChatSession, Skill from app.session.session_schema import PlannedTaskFrame - CapabilityKind = Literal[ "general_skill", "knowledge", @@ -56,6 +57,11 @@ class TaskRequirement(BaseModel): task_frame_id: str kind: Literal["sop", "conversation"] goal: str + # 当前时间(用户时区,含偏移与星期),供模型换算"明天/下周一"等相对 + # 日期。模型无法自知"今天几号",若不注入则依赖它主动去查;真实案例中 + # 它改为让 shell 代算(`date -d tomorrow; date`),GNU 参数在 macOS 上 + # 报错但被 `;` 吞掉退出码,后一条命令补上今天的日期,静默给出错答案。 + current_time: str = "" source_user_message: str = "" out_of_scope_task_intents: list[str] = Field(default_factory=list) requirements: list[str] = Field(default_factory=list) @@ -112,6 +118,7 @@ def compile( published_deliverables: list[dict[str, Any]] | None = None, source_user_message: str | None = None, out_of_scope_task_intents: list[str] | None = None, + client_timezone: str | None = None, ) -> TaskRequirement: current_node = _current_node(skill, frame.target_step_id or session.active_step_id) expected_fields = _text_list((current_node or {}).get("expected_user_info")) @@ -169,6 +176,7 @@ def compile( task_frame_id=str(frame.task_id or ""), kind=frame.kind, goal=goal, + current_time=_current_time_text(client_timezone), source_user_message=str(source_user_message or "").strip()[:4_000], out_of_scope_task_intents=_unique( [str(item or "") for item in out_of_scope_task_intents or []] @@ -189,6 +197,25 @@ def compile( ) +def _current_time_text(client_timezone: str | None = None) -> str: + """当前时间文本;优先用户所在时区,缺省或非法时回退服务端本地时区。 + + 注入错误时区的时间比不注入更危险(看起来权威,模型不会质疑), + 因此时区来源必须显式,且结果始终带 UTC 偏移。 + """ + + zone: ZoneInfo | None = None + name = str(client_timezone or "").strip() + if name: + try: + zone = ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError): + zone = None + now = datetime.now(zone) if zone is not None else datetime.now().astimezone() + weekday = "一二三四五六日"[now.weekday()] + return f"{now.isoformat(timespec='minutes')}(周{weekday})" + + def current_step_capability_refs(skill: Skill | None, step_id: str | None) -> dict[str, list[str]]: node = _current_node(skill, step_id) refs = (node or {}).get("capability_refs") @@ -252,7 +279,13 @@ def _required_step_capabilities( descriptor.capability_id in required_skill_refs or descriptor.name in required_skill_refs ) - if matches_tool or matches_skill: + # 内置能力(如 lark_cli)也允许被 SOP 节点标记为强制执行, + # 使 finish 闸(REQUIRED_CAPABILITY_NOT_INVOKED)同样生效。 + matches_internal = descriptor.kind == "internal" and ( + descriptor.capability_id in required_tool_refs + or descriptor.name in required_tool_refs + ) + if matches_tool or matches_skill or matches_internal: required.append(descriptor.name) if required_knowledge_base_ids: required.append("knowledge_search") diff --git a/backend/app/db/seed_fixtures/staffdeck_expanded_gallery_seed.json b/backend/app/db/seed_fixtures/staffdeck_expanded_gallery_seed.json index 6ae1bca67..ec0a27a3b 100644 --- a/backend/app/db/seed_fixtures/staffdeck_expanded_gallery_seed.json +++ b/backend/app/db/seed_fixtures/staffdeck_expanded_gallery_seed.json @@ -1773,6 +1773,197 @@ "tenant_id": "tenant_demo", "updated_at": "2026-08-18 00:00:00.000000", "version": "1.0.0" + }, + { + "id": "skill_preset_admin_lark_approval_001", + "tenant_id": "tenant_demo", + "skill_id": "lark_approval_submit", + "version": "1.0.0", + "name": "飞书审批提交", + "business_domain": "办公协作", + "description": "通过飞书官方 lark-cli 以用户本人身份提交审批申请:先完成一次性设备码授权登录,再检索审批定义、组装表单并 dry-run 预览,把内容逐字段展示给用户,获得明确确认后才真实提交。", + "content_json": { + "skill_id": "lark_approval_submit", + "name": "飞书审批提交", + "version": "1.0.0", + "business_domain": "办公协作", + "description": "通过飞书官方 lark-cli 以用户本人身份提交审批申请:应用凭据缺失时可在对话内一次性配置(提供现有应用凭据或现场创建新应用),随后完成一次性设备码授权登录,再检索审批定义、组装表单并 dry-run 预览,把内容逐字段展示给用户,获得明确确认后才真实提交。", + "trigger_intents": [ + "提交审批", + "发起审批", + "提审批单", + "帮我走审批流程" + ], + "user_utterance_examples": [ + "帮我提一个报销审批", + "发起一个请假审批,下周一到周三", + "走一下采购审批流程" + ], + "goal": [ + "以用户本人的飞书身份成功创建审批实例,并把审批单链接反馈给用户", + "提交内容必须与用户确认过的预览逐字节一致" + ], + "response_rules": [ + "展示预览时必须逐字段列出表单内容,不得省略", + "用户未明确回复确认前,绝不调用真实提交;但用户已确认且内容未变时不得重复索要确认,直接推进提交", + "工具调用失败必须如实告知用户失败与原因,严禁把失败或\"即将提交\"表述为已提交;提交成功的唯一标志是返回 instance_code;标记 retryable=true 的报错是可恢复错误,不得据此宣告任务失败或提前结束步骤——应按错误信息中的指引修正重试,或如实说明后留在当前步骤等待用户回复", + "所有飞书相关操作只能通过 lark_cli 能力完成,禁止使用 exec_command 等其他方式操作飞书或计算日期(当前时间在任务书 current_time 字段里)", + "任何 lark-cli 错误要翻译成用户能懂的话,并给出下一步建议", + "用户在对话中提供的 App Secret 只用于调用 config init,绝不在回复中复述或展示", + "收集信息时不过度追问:有合理默认或可推断的字段直接采用默认值,预览时统一让用户核对修改" + ], + "start_node_id": "n_auth_check", + "terminal_node_ids": [ + "n_done" + ], + "nodes": [ + { + "node_id": "n_auth_check", + "type": "action", + "name": "检查登录状态", + "instruction": "调用 lark_cli 执行 auth status --json 确认登录态(这是后续所有飞书操作的前提,包括检索审批定义)。若返回错误提示应用未配置(LARK_CLI_APP_NOT_CONFIGURED),进入应用配置步骤。否则查看 identities.user.available 是否为 true(用户身份是否已登录):已登录则进入信息收集步骤;未登录则进入发起授权步骤。" + }, + { + "node_id": "n_app_setup", + "type": "collect_info", + "name": "配置飞书应用", + "instruction": "当前还没有可用的飞书应用凭据,需在对话中完成一次性配置。向用户说明并给出两个选项:A) 已有飞书应用——请用户提供应用的 App ID 和 App Secret(飞书开放平台 https://open.feishu.cn 应用详情页的「凭证与基础信息」可查),拿到后调用 lark_cli 执行 config init --app-id --app-secret ;不要在回复中复述用户提供的 App Secret。B) 没有应用——调用 lark_cli 执行 config init --new,把返回的 verification_url 发给用户,请其在浏览器中登录飞书开放平台完成应用创建并回复完成;用户回复后再次调用 config init --new 查询进度,直到返回 configured。配置成功后回到登录状态检查步骤。注意:新创建的应用还需要在开放平台开通审批相关权限(approval:approval:read、approval:instance:write 等)并发布版本,后续登录步骤会检查并给出指引。", + "expected_user_info": [ + "应用凭据(App ID 与 App Secret)或应用创建完成的确认" + ] + }, + { + "node_id": "n_auth_start", + "type": "collect_info", + "name": "发起授权登录", + "instruction": "进入本步骤后的第一个动作必须是调用 lark_cli 执行 auth login --scope \"approval:approval:read approval:instance:read approval:instance:write approval:task:read approval:task:write\" --no-wait --json,从结果取 verification_url 与 device_code:把 verification_url 原样发给用户,请用户打开链接完成飞书授权并在完成后回复确认;device_code 原样保留用于下一步收尾。不要在登录前调用 auth scopes 查询应用权限——该命令需要应用管理类权限(admin:app.info:readonly 等),普通审批应用并未申请,调用必然失败并白白多耗一轮。若登录或后续审批调用报 app_scope_not_applied,说明该应用后台尚未开通审批权限,请如实告知用户需由管理员在飞书开放平台按错误提示中的链接申请权限并发布版本。不要尝试生成二维码(auth qrcode 等命令未开放,控制台也无法展示本地图片),把链接原样发给用户即可。本步骤在把 verification_url 发给用户之前不算完成;期间任何命令报错都不构成任务失败——如实向用户说明并留在本步骤等待用户回复,严禁直接宣告失败。", + "expected_user_info": [ + "用户已完成飞书授权的确认" + ] + }, + { + "node_id": "n_auth_complete", + "type": "action", + "name": "完成授权登录", + "instruction": "用户确认已授权后,调用 lark_cli 执行 auth login --device-code <上一步的 device_code> --json 完成登录,再用 auth status --json 复核身份与 scope。若提示 authorization_pending,请用户完成授权后重试一次。登录成功后进入信息收集步骤。" + }, + { + "node_id": "n_collect", + "type": "collect_info", + "name": "收集审批需求", + "instruction": "了解用户要提交哪类审批(关键词,如:报销/请假/采购)以及表单需要的信息。用 lark_cli 的 approval approvals search(--data 里带 keyword)检索可发起的审批定义,确认后用 approvals get --approval-code 查看该定义的表单控件结构。据此向用户收集缺失的字段值(一次性问全,不要分多轮);选择类控件要把可选项列给用户(记录每个选项文字对应的 key)。不过度追问:凡是能从用户已给信息合理推断或有惯例默认值的字段,直接采用默认并留到预览步骤让用户核对,不要再问——例如日期给到天即视为全天,按 09:00 至 18:00 组装起止时间。用户在收集阶段回复「提交/确认/用默认」等,视为同意用合理默认补齐全部缺失字段,立即停止提问并进入下一步骤。涉及\"明天/下周一\"等相对日期时,用任务书 current_time 字段换算,不要调用命令获取日期。", + "expected_user_info": [ + "审批类型或关键词", + "表单所需字段的取值" + ] + }, + { + "node_id": "n_preview", + "type": "collect_info", + "name": "预览并等待用户确认", + "instruction": "根据已收集的信息组装 --data:approval_code 加 form。form 组装规范(务必遵守):① 控件 id、type 与嵌套结构必须逐一取自 approvals get 返回的 form 定义,严禁自造控件 id;复合控件(如 leaveGroupV2)的子控件必须嵌套在该复合控件的 value 数组内整体组装,严禁平铺到 form 顶层;② radioV2/select 等选择类控件的 value 必须用定义返回的选项 key,不是选项文字;③ date 控件的值推荐写 RFC3339(如 2026-08-27T09:00:00+08:00);定义详情里显示的 YYYY-MM-DD hh:mm 只是展示格式,系统也会自动把这类常见日期写法规范化为 RFC3339;④ form 传 JSON 数组或字符串均可,系统会自动规范化为官方线上格式。然后调用 lark_cli 执行 approval instances create --data --dry-run,获得预览与 submission_digest。向用户逐字段展示:审批定义名称、每个字段的名称与取值(选择类字段同时展示选项文字)。明确询问用户是否确认提交并等待回复。用户回复确认(如「提交」「确认」「OK」「没问题」)后:严禁再次询问确认,也严禁在本步骤调用真实提交(系统会拒绝)——正确做法是在 finish 时把 slot_updates 写入 {\"用户对提交内容的明确确认\": \"已确认\"} 并给出 next_step_id 进入提交步骤,由提交步骤执行真实提交。用户要求修改时回到收集步骤重新组装并重新预览。", + "expected_user_info": [ + "用户对提交内容的明确确认" + ] + }, + { + "node_id": "n_submit", + "type": "action", + "name": "提交审批", + "instruction": "仅在用户明确确认后执行。进入本步骤后的第一个动作必须是调用 lark_cli 完成真实提交:args 只需 [\"approval\",\"instances\",\"create\"],不带 --data 也不带 confirmed_form_digest——系统会自动提交用户在预览步骤确认过的表单内容并注入 --yes 与幂等 uuid,严禁重新获取审批定义或重新组装表单。此前步骤的 dry-run 预览不等于提交,严禁未调用提交就结束本步骤,也严禁因上一步骤的拒绝报错而直接宣告失败。提交成功的唯一标志是返回结果中包含 instance_code——此时向用户反馈审批单链接。若提交失败:必须如实告知用户失败原因,严禁表述为已提交或即将提交;若系统提示没有可重放的预览记录或表单被服务端拒绝,回到预览步骤重新组装 --data、重新 dry-run 并再次征得用户确认后才能重试提交。", + "capability_refs": { + "tool_ids": [ + "lark_cli" + ], + "required_tool_ids": [ + "lark_cli" + ] + } + }, + { + "node_id": "n_done", + "type": "action", + "name": "完成", + "instruction": "向用户反馈审批已提交成功、审批单链接与后续查看方式(也可用 approval tasks query / instances get 查询进度)。" + }, + { + "node_id": "n_handoff", + "type": "handoff", + "name": "转人工处理", + "instruction": "授权多次失败、审批定义不存在、或提交持续报错且用户希望人工介入时,转交人工处理并附上已收集的上下文。" + } + ], + "edges": [ + { + "source_node_id": "n_auth_check", + "next_node_id": "n_collect", + "condition": "auth status 显示用户身份已登录(identities.user.available 为 true)" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_auth_start", + "condition": "用户身份尚未登录" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_app_setup", + "condition": "报错应用未配置(LARK_CLI_APP_NOT_CONFIGURED)" + }, + { + "source_node_id": "n_app_setup", + "next_node_id": "n_auth_check", + "condition": "应用凭据配置成功(configured)" + }, + { + "source_node_id": "n_auth_start", + "next_node_id": "n_auth_complete", + "condition": "用户回复已完成授权" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_collect", + "condition": "登录成功且 scope 满足" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_handoff", + "condition": "多次尝试后登录仍失败且用户希望人工协助" + }, + { + "source_node_id": "n_collect", + "next_node_id": "n_preview", + "condition": "审批类型与表单信息已收集齐全" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_submit", + "condition": "用户明确确认提交内容无误" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_collect", + "condition": "用户要求修改表单内容" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_done", + "condition": "审批实例创建成功" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_handoff", + "condition": "提交持续失败且用户希望人工协助" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_preview", + "condition": "提交被服务端拒绝(如表单格式错误),需修正后重新预览并让用户确认" + } + ] + }, + "status": "published", + "created_at": "2026-08-26 00:00:00.000000", + "updated_at": "2026-08-26 00:00:00.000000" } ], "skill_versions": [ @@ -2355,6 +2546,197 @@ "tenant_id": "tenant_demo", "updated_at": "2026-08-18 00:00:00.000000", "version": "1.0.0" + }, + { + "id": "skillver_preset_admin_lark_approval_001", + "tenant_id": "tenant_demo", + "skill_id": "lark_approval_submit", + "version": "1.0.0", + "name": "飞书审批提交", + "business_domain": "办公协作", + "description": "通过飞书官方 lark-cli 以用户本人身份提交审批申请:先完成一次性设备码授权登录,再检索审批定义、组装表单并 dry-run 预览,把内容逐字段展示给用户,获得明确确认后才真实提交。", + "content_json": { + "skill_id": "lark_approval_submit", + "name": "飞书审批提交", + "version": "1.0.0", + "business_domain": "办公协作", + "description": "通过飞书官方 lark-cli 以用户本人身份提交审批申请:应用凭据缺失时可在对话内一次性配置(提供现有应用凭据或现场创建新应用),随后完成一次性设备码授权登录,再检索审批定义、组装表单并 dry-run 预览,把内容逐字段展示给用户,获得明确确认后才真实提交。", + "trigger_intents": [ + "提交审批", + "发起审批", + "提审批单", + "帮我走审批流程" + ], + "user_utterance_examples": [ + "帮我提一个报销审批", + "发起一个请假审批,下周一到周三", + "走一下采购审批流程" + ], + "goal": [ + "以用户本人的飞书身份成功创建审批实例,并把审批单链接反馈给用户", + "提交内容必须与用户确认过的预览逐字节一致" + ], + "response_rules": [ + "展示预览时必须逐字段列出表单内容,不得省略", + "用户未明确回复确认前,绝不调用真实提交;但用户已确认且内容未变时不得重复索要确认,直接推进提交", + "工具调用失败必须如实告知用户失败与原因,严禁把失败或\"即将提交\"表述为已提交;提交成功的唯一标志是返回 instance_code;标记 retryable=true 的报错是可恢复错误,不得据此宣告任务失败或提前结束步骤——应按错误信息中的指引修正重试,或如实说明后留在当前步骤等待用户回复", + "所有飞书相关操作只能通过 lark_cli 能力完成,禁止使用 exec_command 等其他方式操作飞书或计算日期(当前时间在任务书 current_time 字段里)", + "任何 lark-cli 错误要翻译成用户能懂的话,并给出下一步建议", + "用户在对话中提供的 App Secret 只用于调用 config init,绝不在回复中复述或展示", + "收集信息时不过度追问:有合理默认或可推断的字段直接采用默认值,预览时统一让用户核对修改" + ], + "start_node_id": "n_auth_check", + "terminal_node_ids": [ + "n_done" + ], + "nodes": [ + { + "node_id": "n_auth_check", + "type": "action", + "name": "检查登录状态", + "instruction": "调用 lark_cli 执行 auth status --json 确认登录态(这是后续所有飞书操作的前提,包括检索审批定义)。若返回错误提示应用未配置(LARK_CLI_APP_NOT_CONFIGURED),进入应用配置步骤。否则查看 identities.user.available 是否为 true(用户身份是否已登录):已登录则进入信息收集步骤;未登录则进入发起授权步骤。" + }, + { + "node_id": "n_app_setup", + "type": "collect_info", + "name": "配置飞书应用", + "instruction": "当前还没有可用的飞书应用凭据,需在对话中完成一次性配置。向用户说明并给出两个选项:A) 已有飞书应用——请用户提供应用的 App ID 和 App Secret(飞书开放平台 https://open.feishu.cn 应用详情页的「凭证与基础信息」可查),拿到后调用 lark_cli 执行 config init --app-id --app-secret ;不要在回复中复述用户提供的 App Secret。B) 没有应用——调用 lark_cli 执行 config init --new,把返回的 verification_url 发给用户,请其在浏览器中登录飞书开放平台完成应用创建并回复完成;用户回复后再次调用 config init --new 查询进度,直到返回 configured。配置成功后回到登录状态检查步骤。注意:新创建的应用还需要在开放平台开通审批相关权限(approval:approval:read、approval:instance:write 等)并发布版本,后续登录步骤会检查并给出指引。", + "expected_user_info": [ + "应用凭据(App ID 与 App Secret)或应用创建完成的确认" + ] + }, + { + "node_id": "n_auth_start", + "type": "collect_info", + "name": "发起授权登录", + "instruction": "进入本步骤后的第一个动作必须是调用 lark_cli 执行 auth login --scope \"approval:approval:read approval:instance:read approval:instance:write approval:task:read approval:task:write\" --no-wait --json,从结果取 verification_url 与 device_code:把 verification_url 原样发给用户,请用户打开链接完成飞书授权并在完成后回复确认;device_code 原样保留用于下一步收尾。不要在登录前调用 auth scopes 查询应用权限——该命令需要应用管理类权限(admin:app.info:readonly 等),普通审批应用并未申请,调用必然失败并白白多耗一轮。若登录或后续审批调用报 app_scope_not_applied,说明该应用后台尚未开通审批权限,请如实告知用户需由管理员在飞书开放平台按错误提示中的链接申请权限并发布版本。不要尝试生成二维码(auth qrcode 等命令未开放,控制台也无法展示本地图片),把链接原样发给用户即可。本步骤在把 verification_url 发给用户之前不算完成;期间任何命令报错都不构成任务失败——如实向用户说明并留在本步骤等待用户回复,严禁直接宣告失败。", + "expected_user_info": [ + "用户已完成飞书授权的确认" + ] + }, + { + "node_id": "n_auth_complete", + "type": "action", + "name": "完成授权登录", + "instruction": "用户确认已授权后,调用 lark_cli 执行 auth login --device-code <上一步的 device_code> --json 完成登录,再用 auth status --json 复核身份与 scope。若提示 authorization_pending,请用户完成授权后重试一次。登录成功后进入信息收集步骤。" + }, + { + "node_id": "n_collect", + "type": "collect_info", + "name": "收集审批需求", + "instruction": "了解用户要提交哪类审批(关键词,如:报销/请假/采购)以及表单需要的信息。用 lark_cli 的 approval approvals search(--data 里带 keyword)检索可发起的审批定义,确认后用 approvals get --approval-code 查看该定义的表单控件结构。据此向用户收集缺失的字段值(一次性问全,不要分多轮);选择类控件要把可选项列给用户(记录每个选项文字对应的 key)。不过度追问:凡是能从用户已给信息合理推断或有惯例默认值的字段,直接采用默认并留到预览步骤让用户核对,不要再问——例如日期给到天即视为全天,按 09:00 至 18:00 组装起止时间。用户在收集阶段回复「提交/确认/用默认」等,视为同意用合理默认补齐全部缺失字段,立即停止提问并进入下一步骤。涉及\"明天/下周一\"等相对日期时,用任务书 current_time 字段换算,不要调用命令获取日期。", + "expected_user_info": [ + "审批类型或关键词", + "表单所需字段的取值" + ] + }, + { + "node_id": "n_preview", + "type": "collect_info", + "name": "预览并等待用户确认", + "instruction": "根据已收集的信息组装 --data:approval_code 加 form。form 组装规范(务必遵守):① 控件 id、type 与嵌套结构必须逐一取自 approvals get 返回的 form 定义,严禁自造控件 id;复合控件(如 leaveGroupV2)的子控件必须嵌套在该复合控件的 value 数组内整体组装,严禁平铺到 form 顶层;② radioV2/select 等选择类控件的 value 必须用定义返回的选项 key,不是选项文字;③ date 控件的值推荐写 RFC3339(如 2026-08-27T09:00:00+08:00);定义详情里显示的 YYYY-MM-DD hh:mm 只是展示格式,系统也会自动把这类常见日期写法规范化为 RFC3339;④ form 传 JSON 数组或字符串均可,系统会自动规范化为官方线上格式。然后调用 lark_cli 执行 approval instances create --data --dry-run,获得预览与 submission_digest。向用户逐字段展示:审批定义名称、每个字段的名称与取值(选择类字段同时展示选项文字)。明确询问用户是否确认提交并等待回复。用户回复确认(如「提交」「确认」「OK」「没问题」)后:严禁再次询问确认,也严禁在本步骤调用真实提交(系统会拒绝)——正确做法是在 finish 时把 slot_updates 写入 {\"用户对提交内容的明确确认\": \"已确认\"} 并给出 next_step_id 进入提交步骤,由提交步骤执行真实提交。用户要求修改时回到收集步骤重新组装并重新预览。", + "expected_user_info": [ + "用户对提交内容的明确确认" + ] + }, + { + "node_id": "n_submit", + "type": "action", + "name": "提交审批", + "instruction": "仅在用户明确确认后执行。进入本步骤后的第一个动作必须是调用 lark_cli 完成真实提交:args 只需 [\"approval\",\"instances\",\"create\"],不带 --data 也不带 confirmed_form_digest——系统会自动提交用户在预览步骤确认过的表单内容并注入 --yes 与幂等 uuid,严禁重新获取审批定义或重新组装表单。此前步骤的 dry-run 预览不等于提交,严禁未调用提交就结束本步骤,也严禁因上一步骤的拒绝报错而直接宣告失败。提交成功的唯一标志是返回结果中包含 instance_code——此时向用户反馈审批单链接。若提交失败:必须如实告知用户失败原因,严禁表述为已提交或即将提交;若系统提示没有可重放的预览记录或表单被服务端拒绝,回到预览步骤重新组装 --data、重新 dry-run 并再次征得用户确认后才能重试提交。", + "capability_refs": { + "tool_ids": [ + "lark_cli" + ], + "required_tool_ids": [ + "lark_cli" + ] + } + }, + { + "node_id": "n_done", + "type": "action", + "name": "完成", + "instruction": "向用户反馈审批已提交成功、审批单链接与后续查看方式(也可用 approval tasks query / instances get 查询进度)。" + }, + { + "node_id": "n_handoff", + "type": "handoff", + "name": "转人工处理", + "instruction": "授权多次失败、审批定义不存在、或提交持续报错且用户希望人工介入时,转交人工处理并附上已收集的上下文。" + } + ], + "edges": [ + { + "source_node_id": "n_auth_check", + "next_node_id": "n_collect", + "condition": "auth status 显示用户身份已登录(identities.user.available 为 true)" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_auth_start", + "condition": "用户身份尚未登录" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_app_setup", + "condition": "报错应用未配置(LARK_CLI_APP_NOT_CONFIGURED)" + }, + { + "source_node_id": "n_app_setup", + "next_node_id": "n_auth_check", + "condition": "应用凭据配置成功(configured)" + }, + { + "source_node_id": "n_auth_start", + "next_node_id": "n_auth_complete", + "condition": "用户回复已完成授权" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_collect", + "condition": "登录成功且 scope 满足" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_handoff", + "condition": "多次尝试后登录仍失败且用户希望人工协助" + }, + { + "source_node_id": "n_collect", + "next_node_id": "n_preview", + "condition": "审批类型与表单信息已收集齐全" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_submit", + "condition": "用户明确确认提交内容无误" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_collect", + "condition": "用户要求修改表单内容" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_done", + "condition": "审批实例创建成功" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_handoff", + "condition": "提交持续失败且用户希望人工协助" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_preview", + "condition": "提交被服务端拒绝(如表单格式错误),需修正后重新预览并让用户确认" + } + ] + }, + "status": "published", + "created_at": "2026-08-26 00:00:00.000000", + "updated_at": "2026-08-26 00:00:00.000000" } ], "tools": [], @@ -2529,6 +2911,23 @@ "status": "active", "tenant_id": "tenant_demo", "updated_at": "2026-08-18 00:00:00.000000" + }, + { + "id": "agentres_preset_admin_lark_approval_001", + "tenant_id": "tenant_demo", + "agent_id": "agent_30b8f623c6fe445b", + "resource_id": "skill_preset_admin_lark_approval_001", + "resource_type": "skill", + "status": "active", + "metadata_json": { + "created_by": "admin", + "created_from_agent": true, + "owner_agent_id": "agent_30b8f623c6fe445b", + "scope": "agent_private", + "visibility": "agent_private" + }, + "created_at": "2026-08-26 00:00:00.000000", + "updated_at": "2026-08-26 00:00:00.000000" } ], "agent_skill_branches": [ diff --git a/backend/app/lark_cli/__init__.py b/backend/app/lark_cli/__init__.py new file mode 100644 index 000000000..0d1e0648c --- /dev/null +++ b/backend/app/lark_cli/__init__.py @@ -0,0 +1,6 @@ +"""飞书官方 lark-cli 集成(受信包装层)。 + +模型只能通过 ``lark_cli`` 内置能力传结构化 argv;本包负责: +二进制供给(provision)、子命令策略(policy)、每用户 HOME 与凭据 +注入及进程执行(runner)、以及带确认闸的调用入口(service)。 +""" diff --git a/backend/app/lark_cli/background.py b/backend/app/lark_cli/background.py new file mode 100644 index 000000000..f9d98f60f --- /dev/null +++ b/backend/app/lark_cli/background.py @@ -0,0 +1,214 @@ +"""``config init --new`` 的后台进程管理(对话内现场创建飞书应用)。 + +官方 CLI 为 agent 设计的流程:进程阻塞直到用户在浏览器完成创建,需要 +后台运行并从输出中提取 verification URL 接力给用户。本模块按用户 HOME +维护一个进程注册表:启动、取 URL、查询进度、超时与孤儿回收。 + +完成判定不依赖进程输出,而以「HOME 下 config.json 出现应用」为准—— +即便后端重启丢失注册表,用户完成创建后重新查询仍能得到 configured。 +""" + +from __future__ import annotations + +import contextlib +import os +import re +import signal +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path + +from app.lark_cli.runner import configured_app_ids, process_environment + +_URL_PATTERN = re.compile(r"https://[^\s\"'<>]+") +_WAIT_FOR_URL_SECONDS = 20.0 +_PROCESS_TTL_SECONDS = 900.0 +_MAX_OUTPUT_CHARS = 20_000 +_PIDFILE_NAME = "config_init_new.pid" + + +@dataclass +class _InitProcess: + process: subprocess.Popen[str] + started_at: float + output: list[str] = field(default_factory=list) + output_chars: int = 0 + verification_url: str | None = None + lock: threading.Lock = field(default_factory=threading.Lock) + reader: threading.Thread | None = None + + +_REGISTRY: dict[str, _InitProcess] = {} +_REGISTRY_LOCK = threading.RLock() + + +def config_init_new_status(binary: Path, home: Path) -> dict[str, object]: + """启动或查询本用户的 ``config init --new`` 后台流程。 + + 返回 dict:``status`` 为 ``configured`` / ``pending_user`` / ``failed``, + 并带模型可直接执行的 ``next_step`` 指引。同一 HOME 重复调用是幂等的 + 查询,不会重复起进程。 + """ + + key = str(home) + with _REGISTRY_LOCK: + # 结果判定优先于进程状态:配置出现即成功。 + apps = configured_app_ids(home) + if apps: + entry = _REGISTRY.pop(key, None) + if entry is not None: + _terminate(entry) + _remove_pidfile(home) + return { + "status": "configured", + "app_ids": sorted(apps), + "next_step": ( + "应用已创建并配置完成。继续调用 auth status 检查用户登录态," + "未登录则走设备码登录流程。" + ), + } + entry = _REGISTRY.get(key) + if entry is not None: + exit_code = entry.process.poll() + if exit_code is not None: + _REGISTRY.pop(key, None) + _remove_pidfile(home) + if entry.reader is not None: + # 让读线程吃完管道残余,避免 tail 截断。 + entry.reader.join(timeout=2) + return _failed( + f"应用创建进程已退出(exit {exit_code})但未产生配置:" + f"{_output_tail(entry)}" + ) + if time.monotonic() - entry.started_at > _PROCESS_TTL_SECONDS: + _REGISTRY.pop(key, None) + _terminate(entry) + _remove_pidfile(home) + return _failed( + f"等待用户完成创建超时({_PROCESS_TTL_SECONDS:.0f}s)," + "进程已回收;可重新发起。" + ) + else: + _kill_stale_pidfile(home) + entry = _spawn(binary, home) + _REGISTRY[key] = entry + url = _wait_for_url(entry) + # 等待 URL 期间进程可能已结束(极快失败/极快完成),重查一次终态。 + if entry.process.poll() is not None: + return config_init_new_status(binary, home) + return { + "status": "pending_user", + "verification_url": url, + "next_step": ( + "把 verification_url 发给用户,请其在浏览器中登录飞书开放平台并完成" + "应用创建;用户回复完成后,再次调用 config init --new 查询进度。" + if url + else "创建进程已启动但尚未输出 verification URL,请稍后再次调用" + " config init --new 查询。" + ), + } + + +def _spawn(binary: Path, home: Path) -> _InitProcess: + process = subprocess.Popen( + [str(binary), "config", "init", "--new"], + cwd=str(home), + env=process_environment(home), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=(os.name == "posix"), + ) + _write_pidfile(home, process.pid) + entry = _InitProcess(process=process, started_at=time.monotonic()) + entry.reader = threading.Thread(target=_read_output, args=(entry,), daemon=True) + entry.reader.start() + return entry + + +def _read_output(entry: _InitProcess) -> None: + stream = entry.process.stdout + if stream is None: + return + for line in stream: + with entry.lock: + if entry.output_chars < _MAX_OUTPUT_CHARS: + entry.output.append(line) + entry.output_chars += len(line) + if entry.verification_url is None: + match = _URL_PATTERN.search(line) + if match: + entry.verification_url = match.group(0).rstrip(".,;)") + + +def _wait_for_url(entry: _InitProcess) -> str | None: + deadline = time.monotonic() + _WAIT_FOR_URL_SECONDS + while time.monotonic() < deadline: + with entry.lock: + if entry.verification_url: + return entry.verification_url + if entry.process.poll() is not None: + break + time.sleep(0.2) + with entry.lock: + return entry.verification_url + + +def _output_tail(entry: _InitProcess) -> str: + with entry.lock: + return "".join(entry.output)[-800:].strip() or "(无输出)" + + +def _failed(detail: str) -> dict[str, object]: + return {"status": "failed", "detail": detail} + + +def _terminate(entry: _InitProcess) -> None: + if entry.process.poll() is not None: + return + _kill_pid(entry.process.pid) + with contextlib.suppress(Exception): + entry.process.wait(timeout=5) + + +def _pidfile(home: Path) -> Path: + tmp = home / "tmp" + tmp.mkdir(exist_ok=True) + return tmp / _PIDFILE_NAME + + +def _write_pidfile(home: Path, pid: int) -> None: + with contextlib.suppress(OSError): + _pidfile(home).write_text(str(pid), encoding="utf-8") + + +def _remove_pidfile(home: Path) -> None: + with contextlib.suppress(OSError): + _pidfile(home).unlink(missing_ok=True) + + +def _kill_stale_pidfile(home: Path) -> None: + """后端重启会丢注册表;孤儿的阻塞进程按 pidfile 回收后再新起。""" + + try: + pid = int(_pidfile(home).read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return + _kill_pid(pid) + _remove_pidfile(home) + + +def _kill_pid(pid: int) -> None: + if pid <= 0: + return + if os.name == "posix": + try: + os.killpg(pid, signal.SIGKILL) + return + except (ProcessLookupError, PermissionError): + pass + with contextlib.suppress(OSError): + os.kill(pid, signal.SIGKILL) diff --git a/backend/app/lark_cli/policy.py b/backend/app/lark_cli/policy.py new file mode 100644 index 000000000..64934700c --- /dev/null +++ b/backend/app/lark_cli/policy.py @@ -0,0 +1,572 @@ +"""lark-cli 子命令策略表。 + +分层策略(读宽写严): + +- 显式登记的**读命令**放行任意 flag(CLI 官方 typed 参数如 ``--approval-code`` + 直接可用——曾因白名单过窄拦下官方正确用法,逼模型反复试错);仅保留 + 硬约束:禁 ``--yes``、禁 ``@file``/stdin 值引用、``--as`` 只能 user。 +- **未登记的命令**返回 ``needs_risk_probe`` 候选,由 service 层跑 + ``--help`` 读取 CLI 自带的 ``Risk:`` 分级:``read`` 放行,写类拒绝。 +- **写命令**维持严格白名单;``--yes`` 与 ``uuid`` 一律由受信代码管理。 +- ``api`` 原始逃生舱(可发任意请求)永久封禁;``config`` 域仅放行 + ``init``(对话内配置凭据 / ``--new`` 现场创建应用),由 service 特殊 + 路由执行。 +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from dataclasses import dataclass, field +from datetime import datetime + + +class LarkCliPolicyError(Exception): + """argv 被策略拒绝;``code`` 会透传给模型作为可恢复错误。""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class CommandRule: + prefix: tuple[str, ...] + action: str # "read" | "write" | "gated_write" + allowed_flags: frozenset[str] = field(default_factory=frozenset) + timeout_seconds: float = 60.0 + + +_OUTPUT_FLAGS = frozenset({"--format", "--json", "--jq", "-q"}) +_DATA_FLAGS = frozenset({"--data", "--params"}) + +_RULES: tuple[CommandRule, ...] = ( + CommandRule(("auth", "status"), "read", _OUTPUT_FLAGS), + CommandRule(("auth", "check"), "read", _OUTPUT_FLAGS | {"--scope"}), + # 免确认写名单的收录标准:仅限"纯本地态操作"——只动本机 token/配置、 + # 不触达任何他人、不在租户内产生可见副作用(auth login/logout、config + # init)。CLI 的 Risk: write 档里混有真实外部副作用的命令(如 im + # messages send 发消息、+chat-create 建群),那些永远不进本名单—— + # 未来若要开放,走"用户确认后执行"的通用确认闸,不是加名单。 + CommandRule( + ("auth", "login"), + "write", + _OUTPUT_FLAGS + | {"--scope", "--domain", "--recommend", "--exclude", "--no-wait", "--device-code"}, + timeout_seconds=120.0, + ), + CommandRule(("auth", "logout"), "write", _OUTPUT_FLAGS), + CommandRule(("auth", "scopes"), "read", _OUTPUT_FLAGS), + # --app-secret 是工具层约定的虚拟 flag:CLI 实际只收 --app-secret-stdin, + # service 会截下值经 stdin 注入并做输出脱敏。--force-init 刻意不放行。 + CommandRule( + ("config", "init"), + "write", + _OUTPUT_FLAGS | {"--new", "--app-id", "--app-secret", "--brand", "--lang"}, + timeout_seconds=90.0, + ), + CommandRule( + ("approval", "approvals", "search"), "read", _OUTPUT_FLAGS | _DATA_FLAGS | {"--as"} + ), + CommandRule( + ("approval", "approvals", "get"), "read", _OUTPUT_FLAGS | _DATA_FLAGS | {"--as"} + ), + CommandRule( + ("approval", "instances", "create"), + "gated_write", + _OUTPUT_FLAGS | _DATA_FLAGS | {"--as", "--dry-run"}, + ), + CommandRule(("approval", "instances", "get"), "read", _OUTPUT_FLAGS | _DATA_FLAGS | {"--as"}), + CommandRule( + ("approval", "instances", "initiated"), + "read", + _OUTPUT_FLAGS | _DATA_FLAGS | {"--as"}, + ), + CommandRule(("approval", "tasks", "query"), "read", _OUTPUT_FLAGS | _DATA_FLAGS | {"--as"}), + CommandRule(("schema",), "read", _OUTPUT_FLAGS), + CommandRule(("skills", "list"), "read", _OUTPUT_FLAGS), + CommandRule(("skills", "read"), "read", _OUTPUT_FLAGS), +) + +_MAX_ARGS = 32 +_MAX_ARG_CHARS = 8_000 +_HELP_ALLOWED_DOMAINS = {"auth", "approval", "schema", "skills", "config"} + + +@dataclass(frozen=True) +class ResolvedCommand: + rule: CommandRule + argv: tuple[str, ...] + is_dry_run: bool + is_help: bool + data_json: dict[str, object] | None + # 未登记命令的动态候选:需要 service 层用 CLI 自带的 Risk 分级裁决。 + needs_risk_probe: bool = False + + @property + def is_side_effect_write(self) -> bool: + if self.rule.action == "read" or self.is_help: + return False + return not self.is_dry_run + + +def resolve(args: list[str]) -> ResolvedCommand: + """校验模型传入的 argv 并解析出策略裁决。""" + + if not args: + raise LarkCliPolicyError("LARK_CLI_EMPTY_COMMAND", "args 不能为空。") + if len(args) > _MAX_ARGS: + raise LarkCliPolicyError("LARK_CLI_TOO_MANY_ARGS", f"args 数量超过 {_MAX_ARGS}。") + normalized: list[str] = [] + for item in args: + text = str(item or "") + if not text.strip(): + raise LarkCliPolicyError("LARK_CLI_BLANK_ARG", "args 不允许包含空白项。") + if len(text) > _MAX_ARG_CHARS: + raise LarkCliPolicyError( + "LARK_CLI_ARG_TOO_LONG", f"单个参数超过 {_MAX_ARG_CHARS} 字符。" + ) + if "\x00" in text or "\n" in text or "\r" in text: + raise LarkCliPolicyError("LARK_CLI_ILLEGAL_CHARS", "参数包含非法控制字符。") + normalized.append(text) + + rule = _match_rule(normalized) + if rule is None: + # 允许对白名单域的任意层级查看 --help(如 `approval instances --help`): + # 纯只读,帮模型现场自查用法,避免试错烧预算。 + if ( + normalized[0] in _HELP_ALLOWED_DOMAINS + and normalized[-1] in {"--help", "-h"} + and all(not item.startswith("-") for item in normalized[:-1]) + ): + help_rule = CommandRule(tuple(normalized[:-1]), "read", frozenset({"--help"})) + return ResolvedCommand( + rule=help_rule, + argv=tuple(normalized), + is_dry_run=False, + is_help=True, + data_json=None, + ) + return _resolve_dynamic_candidate(normalized) + if rule.action == "read": + flag_values = _validate_relaxed(normalized[len(rule.prefix):]) + else: + flag_values = _validate_flags(normalized[len(rule.prefix):], rule) + data_json = ( + _parse_data_json(flag_values.get("--data")) + if rule.action == "gated_write" + else None + ) + return ResolvedCommand( + rule=rule, + argv=tuple(normalized), + is_dry_run="--dry-run" in flag_values, + is_help="--help" in flag_values, + data_json=data_json, + ) + + +def _resolve_dynamic_candidate(normalized: list[str]) -> ResolvedCommand: + """未登记命令:交给 service 层按 CLI 的 ``Risk:`` 分级动态裁决。 + + ``api``(任意请求逃生舱)与 ``config`` 其余子命令永远不进入动态通道。 + """ + + domain = normalized[0] + if domain in {"api", "config"}: + raise LarkCliPolicyError( + "LARK_CLI_COMMAND_BLOCKED", + "该 lark-cli 子命令被封禁(api 原始逃生舱与 config 其余子命令不开放)。", + ) + prefix: list[str] = [] + for token in normalized: + if token.startswith("-"): + break + prefix.append(token) + if not prefix: + raise LarkCliPolicyError( + "LARK_CLI_COMMAND_BLOCKED", "无法识别的 lark-cli 命令形式。" + ) + if tuple(prefix[:2]) == ("auth", "qrcode"): + # 真实案例:模型每次登录都先试 qrcode 渲染二维码,白烧一轮才回退 + # 发链接。与其走 risk-probe 拒绝,不如直接给出正确动作。 + raise LarkCliPolicyError( + "LARK_CLI_COMMAND_BLOCKED", + "auth qrcode(终端二维码渲染)在托管环境不开放:控制台无法展示" + "本地生成的二维码,也不需要二维码——把 auth login 返回的 " + "verification_url 链接原样发给用户点击打开即可。", + ) + flag_values = _validate_relaxed(normalized[len(prefix):]) + return ResolvedCommand( + rule=CommandRule(tuple(prefix), "read", frozenset()), + argv=tuple(normalized), + is_dry_run="--dry-run" in flag_values, + is_help="--help" in flag_values, + data_json=None, + needs_risk_probe=True, + ) + + +def _validate_relaxed(rest: list[str]) -> dict[str, str | None]: + """读命令的宽松校验:任意 flag 透传,仅执行硬约束。 + + 硬约束:禁 ``--yes``(确认标志只能由受信代码注入)、禁 ``@file`` 与 + ``-``(stdin)值引用(防本地文件外带)、``--as`` 只能取 ``user``。 + """ + + values: dict[str, str | None] = {} + previous_flag: str | None = None + for token in rest: + if token in {"--help", "-h"}: + values["--help"] = None + previous_flag = None + continue + if token in {"--yes", "-y"}: + raise LarkCliPolicyError( + "LARK_CLI_YES_NOT_ALLOWED", + "禁止自带 --yes:高危提交的确认标志由系统在用户确认后注入。", + ) + if token.startswith("-") and len(token) > 1: + values[token] = None + previous_flag = token + continue + if token == "-" or token.startswith("@"): + raise LarkCliPolicyError( + "LARK_CLI_FILE_REF_BLOCKED", + "参数值不允许 @file 或 - (stdin) 引用,请内联内容。", + ) + if previous_flag is not None: + values[previous_flag] = token + previous_flag = None + # 无前置 flag 的位置参数直接透传,由 CLI 校验。 + as_value = values.get("--as") + if "--as" in values and as_value not in (None, "user"): + raise LarkCliPolicyError( + "LARK_CLI_IDENTITY_BLOCKED", "审批命令只允许 --as user 身份执行。" + ) + return values + + +def canonical_submission_body(data_json: dict[str, object] | None) -> dict[str, object] | None: + """提交体的语义规范形:``form`` 统一解析为结构(数组/字符串编码等价), + 日期值统一规范化为 RFC3339(写法差异不改变语义摘要)。 + + 飞书 API 要求 form 是 JSON 字符串,但模型常传数组——两种编码语义相同, + 摘要必须一致,否则"编码自修正"会被确认闸误杀(真实案例)。 + """ + + if not isinstance(data_json, dict) or not data_json: + return None + body: dict[str, object] = { + key: value for key, value in data_json.items() if key != "uuid" + } + structure = _parsed_form_structure(body.get("form")) + if structure is not None: + body["form"] = structure + return body + + +def wire_submission_data(data_json: dict[str, object]) -> dict[str, object]: + """转成 CLI/API 线上格式:``form`` 序列化为 JSON 字符串(官方要求), + 日期值规范化为 RFC3339。 + + 审批定义详情把 date 控件展示为 ``YYYY-MM-DD hh:mm``,模型照抄该格式 + 组装 form 后提交会被服务端以 "not RFC3339" 拒绝(真实案例)——定义 + 自身的展示格式与提交格式不一致,只能由受信层兜底转换。 + """ + + wire = dict(data_json) + structure = _parsed_form_structure(wire.get("form")) + if structure is not None: + wire["form"] = json.dumps(structure, ensure_ascii=False, separators=(",", ":")) + return wire + + +def _parsed_form_structure(form: object) -> object | None: + """form 的规范结构:字符串先解析,再递归规范化日期;非 form 内容返回 None。""" + + if isinstance(form, str): + try: + structure = json.loads(form) + except json.JSONDecodeError as exc: + raise LarkCliPolicyError( + "LARK_CLI_FORM_INVALID_JSON", f"form 字符串不是合法 JSON:{exc}" + ) + elif isinstance(form, (list, dict)): + structure = copy.deepcopy(form) + else: + return None + _normalize_form_dates(structure) + return structure + + +def _normalize_form_dates(structure: object) -> None: + """原地规范化控件树里的日期值:date 控件与 dateInterval 的 start/end。 + + 复合控件(leaveGroupV2、fieldList 等)的 value 是嵌套控件数组,递归处理。 + 无法解析的值原样保留,交给服务端报错。 + """ + + controls = structure if isinstance(structure, list) else [structure] + for control in controls: + if not isinstance(control, dict): + continue + control_type = str(control.get("type") or "") + value = control.get("value") + if control_type == "date" and isinstance(value, str): + control["value"] = _rfc3339(value) + elif control_type == "dateInterval" and isinstance(value, dict): + for key in ("start", "end"): + if isinstance(value.get(key), str): + value[key] = _rfc3339(value[key]) + elif isinstance(value, list): + _normalize_form_dates(value) + + +def _rfc3339(text: str) -> str: + """把常见日期写法(YYYY-MM-DD [HH:MM[:SS]]、ISO 无时区、Z 后缀等) + 规范化为带本机时区偏移的 RFC3339;解析失败原样返回。""" + + try: + parsed = datetime.fromisoformat(text.strip()) + except ValueError: + return text + if parsed.tzinfo is None: + parsed = parsed.astimezone() + return parsed.isoformat(timespec="seconds") + + +def validate_form_against_definition( + form: object, definition_widgets: object +) -> list[str]: + """dry-run 前的表单结构校验:控件 id/type/嵌套/选项 key 必须与定义一致。 + + 背景:CLI 的 ``--dry-run`` 只本地打印请求、不打服务端——编造的控件 + 结构能一路通过预览、被用户确认,直到真实提交才被服务端打回(真实 + 案例:把复合控件的子控件 ``widgetLeaveGroupType`` 平铺到 form 顶层)。 + 受信层用定义原文把这类错误拦在预览之前。只报确定性错误;缺省控件等 + 宽松处交给服务端裁决。返回错误列表,空即通过。 + """ + + if not isinstance(form, list) or not isinstance(definition_widgets, list): + return [] + # 定义索引:控件 id → (定义节点, 父复合控件 id 或 None) + index: dict[str, tuple[dict[str, object], str | None]] = {} + + def _walk(widgets: list[object], parent: str | None) -> None: + for widget in widgets: + if not isinstance(widget, dict) or not widget.get("id"): + continue + widget_id = str(widget["id"]) + index.setdefault(widget_id, (widget, parent)) + value = widget.get("value") + if isinstance(value, list) and any( + isinstance(child, dict) and child.get("id") for child in value + ): + _walk(value, widget_id) + + _walk(definition_widgets, None) + errors: list[str] = [] + + def _check(controls: list[object], parent: str | None) -> None: + for control in controls: + if not isinstance(control, dict): + continue + control_id = str(control.get("id") or "") + if not control_id: + errors.append("存在缺少 id 的控件。") + continue + entry = index.get(control_id) + if entry is None: + errors.append( + f"控件 id {control_id!r} 在审批定义中不存在" + "(控件 id 必须逐一取自 approvals get 返回的定义,严禁自造)。" + ) + continue + spec, expected_parent = entry + if expected_parent != parent: + if expected_parent and not parent: + errors.append( + f"控件 {control_id!r} 是复合控件 {expected_parent!r} 的" + f"子控件,必须嵌套在 {expected_parent!r} 的 value 数组内" + "整体提交,不得平铺到 form 顶层。" + ) + else: + errors.append( + f"控件 {control_id!r} 的嵌套位置与定义不符" + f"(定义中其父级为 {expected_parent or 'form 顶层'})。" + ) + spec_type = str(spec.get("type") or "") + control_type = str(control.get("type") or "") + if spec_type and control_type and control_type != spec_type: + errors.append( + f"控件 {control_id!r} 的 type 应为 {spec_type!r}," + f"实际是 {control_type!r}。" + ) + value = control.get("value") + option = spec.get("option") + if isinstance(option, list) and option: + keys = { + str(item.get("value")) + for item in option + if isinstance(item, dict) + } + by_text = { + str(item.get("text")): str(item.get("value")) + for item in option + if isinstance(item, dict) + } + candidates = ( + [value] + if isinstance(value, str) + else [v for v in value if isinstance(v, str)] + if isinstance(value, list) + else [] + ) + for candidate in candidates: + if candidate not in keys: + hint = ( + f";若想选 {candidate!r},对应 key 是 " + f"{by_text[candidate]!r}" + if candidate in by_text + else "" + ) + errors.append( + f"控件 {control_id!r} 的取值必须用选项 key" + f"(可选:{sorted(keys)}),不是选项文字{hint}。" + ) + spec_children = spec.get("value") + if isinstance(spec_children, list) and any( + isinstance(child, dict) and child.get("id") + for child in spec_children + ): + if isinstance(value, list): + _check(value, control_id) + else: + errors.append( + f"复合控件 {control_id!r} 的 value 必须是子控件数组。" + ) + + _check(form, None) + return errors + + +def submission_digest(data_json: dict[str, object] | None) -> str | None: + """对提交体的语义规范形做摘要;编码差异不改变摘要,内容差异必然改变。""" + + body = canonical_submission_body(data_json) + if body is None: + return None + canonical = json.dumps(body, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def logical_write_signature(arguments: dict[str, object]) -> str | None: + """真实提交(gated_write 且非 dry-run)的防重放签名,其余返回 None。 + + ``auth login`` 虽是写操作,但设备码轮询天然需要重试且无外部副作用 + 风险,纳入防重放反而会把合法重试挡死,故刻意排除。 + """ + + raw_args = arguments.get("args") + if not isinstance(raw_args, list): + return None + try: + resolved = resolve([str(item) for item in raw_args]) + except LarkCliPolicyError: + return None + if resolved.rule.action != "gated_write" or not resolved.is_side_effect_write: + return None + try: + digest = submission_digest(resolved.data_json) or "" + except LarkCliPolicyError: + return None + canonical = json.dumps( + { + "prefix": list(resolved.rule.prefix), + "digest": digest, + # 语义签名不含 argv:同一提交内容无论编码/旗标顺序都命中同一声明。 + }, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _match_rule(argv: list[str]) -> CommandRule | None: + for rule in _RULES: + if tuple(argv[: len(rule.prefix)]) == rule.prefix: + return rule + return None + + +def _validate_flags(rest: list[str], rule: CommandRule) -> dict[str, str | None]: + """校验前缀之后的 token:只允许规则声明的 flag 及其值。""" + + values: dict[str, str | None] = {} + index = 0 + # --help 对任何白名单命令都放行:纯只读,让模型能现场自查用法。 + boolean_flags = {"--json", "--dry-run", "--no-wait", "--recommend", "--new", "--help", "-h"} + while index < len(rest): + token = rest[index] + if token in {"--help", "-h"}: + values["--help"] = None + index += 1 + continue + if token in {"--yes", "-y"}: + raise LarkCliPolicyError( + "LARK_CLI_YES_NOT_ALLOWED", + "禁止自带 --yes:高危提交的确认标志由系统在用户确认后注入。", + ) + if not token.startswith("-"): + # schema/skills 允许一个位置参数(如 schema approval.instances.create)。 + if rule.prefix[0] in {"schema", "skills"} and "positional" not in values: + values["positional"] = token + index += 1 + continue + raise LarkCliPolicyError( + "LARK_CLI_UNEXPECTED_ARG", f"命令不接受位置参数:{token!r}。" + ) + if token not in rule.allowed_flags: + raise LarkCliPolicyError( + "LARK_CLI_FLAG_BLOCKED", f"flag {token!r} 不在该命令的允许列表内。" + ) + if token in boolean_flags: + values[token] = None + index += 1 + continue + if index + 1 >= len(rest): + raise LarkCliPolicyError("LARK_CLI_FLAG_VALUE_MISSING", f"{token} 缺少值。") + value = rest[index + 1] + if token in _DATA_FLAGS and (value.startswith("@") or value == "-"): + raise LarkCliPolicyError( + "LARK_CLI_FILE_REF_BLOCKED", + "--data/--params 不允许 @file 或 - (stdin) 引用,请内联 JSON。", + ) + if token == "--as" and value != "user": + raise LarkCliPolicyError( + "LARK_CLI_IDENTITY_BLOCKED", "审批命令只允许 --as user 身份执行。" + ) + values[token] = value + index += 2 + return values + + +def _parse_data_json(raw: str | None) -> dict[str, object] | None: + if raw is None: + return None + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise LarkCliPolicyError("LARK_CLI_DATA_INVALID_JSON", f"--data 不是合法 JSON:{exc}") + if not isinstance(parsed, dict): + raise LarkCliPolicyError("LARK_CLI_DATA_NOT_OBJECT", "--data 必须是 JSON object。") + if "uuid" in parsed: + raise LarkCliPolicyError( + "LARK_CLI_UUID_NOT_ALLOWED", + "禁止自带 uuid:幂等标识由系统生成,防止绕过防重放。", + ) + return parsed diff --git a/backend/app/lark_cli/provision.py b/backend/app/lark_cli/provision.py new file mode 100644 index 000000000..deb3ce296 --- /dev/null +++ b/backend/app/lark_cli/provision.py @@ -0,0 +1,172 @@ +"""lark-cli 二进制供给。 + +**懒加载**:安装发生在首次真正执行 lark-cli 命令时(``service.invoke_lark_cli`` +调用 ``ensure_lark_cli``),不在服务启动、不在构建期;源码树里不含任何二进制。 +代价是首个用户的首次调用会同步等待安装(约 46MB,超时上限 600s)——换来的是 +从不使用飞书能力的部署完全不产生下载。 + +解析顺序: +1. ``lark_cli_binary_path``:管理员显式指定,不存在即报错(离线部署走这条)。 +2. 受管副本:``user_data_dir()/lark_cli/runtime/node_modules/@larksuite/cli/bin/``, + 存在即直接使用,无任何网络动作。 +3. 自动安装(``lark_cli_auto_install`` 为真时):``npm install`` 官方包后, + 显式执行包内 ``scripts/install.js`` 下载对应平台二进制。 + +安装的三个刻意选择:版本锁死在 ``PINNED_VERSION`` 且不自动升级(上游变更不得 +静默改变生产行为);``--ignore-scripts`` 关掉 npm 生命周期脚本、改为显式调用官方 +安装器,使"下载二进制"是一次有意为之的动作而非装包副作用;二进制完整性由官方 +安装器自带的 checksums.txt SHA-256 校验保证,不重复实现。 + +并发安全:进程内 RLock + 跨进程文件锁,模式照抄 general_skills/runtime_env.py。 +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import BinaryIO + +from app import paths +from app.config import get_settings + +PINNED_VERSION = "1.0.89" +_INSTALL_TIMEOUT_SECONDS = 600.0 +_PREPARE_LOCK = threading.RLock() + + +class LarkCliProvisionError(Exception): + pass + + +def install_root() -> Path: + return paths.user_data_dir() / "lark_cli" / "runtime" + + +def _managed_binary_path() -> Path: + name = "lark-cli.exe" if sys.platform == "win32" else "lark-cli" + return install_root() / "node_modules" / "@larksuite" / "cli" / "bin" / name + + +def ensure_lark_cli() -> Path: + """返回可执行的 lark-cli 路径;必要时自动安装。""" + + configured = str(get_settings().lark_cli_binary_path or "").strip() + if configured: + binary = Path(configured).expanduser() + if not binary.is_file(): + raise LarkCliProvisionError( + f"lark_cli_binary_path 指向的文件不存在:{binary}" + ) + return binary + binary = _managed_binary_path() + if binary.is_file(): + return binary + if not get_settings().lark_cli_auto_install: + raise LarkCliProvisionError( + "lark-cli 未安装且自动安装已关闭;请手动安装后配置 lark_cli_binary_path。" + ) + with _PREPARE_LOCK, _provision_file_lock(): + if binary.is_file(): + return binary + _install_managed_copy() + if not binary.is_file(): + raise LarkCliProvisionError("lark-cli 安装流程结束但未找到二进制。") + return binary + + +def _install_managed_copy() -> None: + npm = shutil.which("npm") + node = shutil.which("node") + if not npm or not node: + raise LarkCliProvisionError( + "自动安装 lark-cli 需要 node/npm;请安装 Node.js 或手动配置 lark_cli_binary_path。" + ) + root = install_root() + root.mkdir(parents=True, exist_ok=True) + _run_step( + [ + npm, + "install", + f"@larksuite/cli@{PINNED_VERSION}", + "--prefix", + str(root), + "--no-audit", + "--no-fund", + # 关掉生命周期脚本:下载二进制改由下面显式调用官方安装器完成。 + "--ignore-scripts", + ], + cwd=root, + step="npm install", + ) + package_dir = root / "node_modules" / "@larksuite" / "cli" + installer = package_dir / "scripts" / "install.js" + if not installer.is_file(): + raise LarkCliProvisionError("npm 安装完成但官方包内缺少 scripts/install.js。") + # 官方 install.js 自带 checksums.txt SHA-256 校验,二进制完整性由它保证。 + _run_step([node, str(installer)], cwd=package_dir, step="binary download") + + +def _run_step(argv: list[str], *, cwd: Path, step: str) -> None: + try: + completed = subprocess.run( + argv, + cwd=str(cwd), + capture_output=True, + text=True, + timeout=_INSTALL_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise LarkCliProvisionError(f"lark-cli {step} 超时。") from exc + except OSError as exc: + raise LarkCliProvisionError(f"lark-cli {step} 启动失败:{exc}") from exc + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip()[-2_000:] + raise LarkCliProvisionError( + f"lark-cli {step} 失败(exit {completed.returncode}):{detail}" + ) + + +@contextmanager +def _provision_file_lock() -> Iterator[None]: + lock_path = paths.user_data_dir() / "lark-cli-provision.lock" + handle = lock_path.open("a+b") + try: + _lock_file(handle) + yield + finally: + _unlock_file(handle) + handle.close() + + +def _lock_file(handle: BinaryIO) -> None: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + if handle.read(1) == b"": + handle.write(b"0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + return + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + + +def _unlock_file(handle: BinaryIO) -> None: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + return + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) diff --git a/backend/app/lark_cli/runner.py b/backend/app/lark_cli/runner.py new file mode 100644 index 000000000..e876bdd69 --- /dev/null +++ b/backend/app/lark_cli/runner.py @@ -0,0 +1,223 @@ +"""lark-cli 进程执行:每用户 HOME、凭据注入、最小环境、限时限量。 + +登录态与密钥全部落在 ``user_data_dir()/lark_cli/homes///`` +(探针已验证 lark-cli 会把配置写进 ``$HOME/.lark-cli/`` 并把 app secret +加密进 ``$HOME/Library/Application Support/lark-cli/``,master key 在无钥匙 +串环境自动降级为本地文件),因此按 HOME 隔离即可实现按用户隔离。 +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from app import paths + +_MAX_OUTPUT_CHARS = 120_000 +_SAFE_KEY_PATTERN = re.compile(r"[^A-Za-z0-9_.-]") + + +class LarkCliRunError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class LarkCliResult: + exit_code: int + stdout: str + stderr: str + envelope: dict[str, object] | None + + +def user_home_dir(tenant_id: str, user_key: str) -> Path: + home = ( + paths.user_data_dir() + / "lark_cli" + / "homes" + / _safe_component(tenant_id) + / _safe_component(user_key) + ) + home.mkdir(parents=True, exist_ok=True) + home.chmod(stat.S_IRWXU) + return home + + +def configured_app_ids(home: Path) -> set[str]: + """config.json 中已配置的全部 appId(CLI 支持多 app,匹配任意一个即视为已配置)。""" + + config_path = home / ".lark-cli" / "config.json" + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + found: set[str] = set() + apps = raw.get("apps") if isinstance(raw, dict) else None + if isinstance(apps, list): + for app in apps: + if isinstance(app, dict) and str(app.get("appId") or "").strip(): + found.add(str(app["appId"]).strip()) + # 兼容旧的单 app 结构。 + if isinstance(raw, dict) and str(raw.get("appId") or "").strip(): + found.add(str(raw["appId"]).strip()) + return found + + +def ensure_configured( + binary: Path, + home: Path, + *, + app_id: str, + app_secret: str, + brand: str = "feishu", +) -> None: + """幂等注入应用凭据;密钥经 stdin 传入,绝不进 argv 与日志。""" + + if app_id in configured_app_ids(home): + return + result = run_lark_cli( + binary, + home, + ["config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", brand], + stdin_text=app_secret, + timeout_seconds=60.0, + redact=(app_secret,), + ) + if app_id not in configured_app_ids(home): + detail = _first_error_message(result) or result.stderr.strip()[:500] + raise LarkCliRunError( + "LARK_CLI_CONFIG_INIT_FAILED", + f"lark-cli 凭据初始化失败:{detail or '未知原因'}", + ) + + +def run_lark_cli( + binary: Path, + home: Path, + argv: list[str], + *, + timeout_seconds: float, + stdin_text: str | None = None, + redact: tuple[str, ...] = (), +) -> LarkCliResult: + env = process_environment(home) + process = subprocess.Popen( + [str(binary), *argv], + cwd=str(home), + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=(os.name == "posix"), + ) + try: + stdout, stderr = process.communicate(input=stdin_text, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + _kill_process_tree(process) + stdout, stderr = process.communicate() + raise LarkCliRunError( + "LARK_CLI_TIMEOUT", + f"lark-cli 命令超时({timeout_seconds:.0f}s)已终止;可稍后重试。", + ) from None + stdout = _redact(stdout[:_MAX_OUTPUT_CHARS], redact) + stderr = _redact(stderr[:_MAX_OUTPUT_CHARS], redact) + return LarkCliResult( + exit_code=int(process.returncode or 0), + stdout=stdout, + stderr=stderr, + envelope=_parse_envelope(stdout) or _parse_envelope(stderr), + ) + + +def process_environment(home: Path) -> dict[str, str]: + """lark-cli 子进程的最小环境(background.py 的长驻进程也复用)。""" + + tmp = home / "tmp" + tmp.mkdir(exist_ok=True) + env = { + "HOME": str(home), + "TMPDIR": str(tmp), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "CI": "1", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + } + if sys.platform == "win32": + env["USERPROFILE"] = str(home) + for key in ("SystemRoot", "TEMP", "TMP", "PATH"): + value = os.environ.get(key) + if value: + env[key] = value + return env + + +def _kill_process_tree(process: subprocess.Popen[str]) -> None: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + return + except (ProcessLookupError, PermissionError): + pass + process.kill() + + +def _parse_envelope(text: str) -> dict[str, object] | None: + """CLI 输出是一或多个 JSON 块(可能混有提示行);取最后一个合法 object。""" + + stripped = text.strip() + if not stripped: + return None + try: + parsed = json.loads(stripped) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + pass + decoder = json.JSONDecoder() + last: dict[str, object] | None = None + index = 0 + while index < len(stripped): + brace = stripped.find("{", index) + if brace < 0: + break + try: + candidate, end = decoder.raw_decode(stripped, brace) + except json.JSONDecodeError: + index = brace + 1 + continue + if isinstance(candidate, dict): + last = candidate + index = end + return last + + +def _first_error_message(result: LarkCliResult) -> str | None: + envelope = result.envelope + if isinstance(envelope, dict): + error = envelope.get("error") + if isinstance(error, dict) and error.get("message"): + return str(error["message"]) + return None + + +def _redact(text: str, secrets: tuple[str, ...]) -> str: + for secret in secrets: + if secret: + text = text.replace(secret, "***") + return text + + +def _safe_component(value: str) -> str: + text = _SAFE_KEY_PATTERN.sub("_", str(value or "").strip()) or "default" + return text[:80] diff --git a/backend/app/lark_cli/service.py b/backend/app/lark_cli/service.py new file mode 100644 index 000000000..4cf67e61b --- /dev/null +++ b/backend/app/lark_cli/service.py @@ -0,0 +1,712 @@ +"""``lark_cli`` 内置能力的调用入口(由 HarnessCapabilityInvoker 转发)。 + +职责:身份与凭据解析 → 策略裁决 → 确认闸(真实提交必须命中本会话已 +落库的 dry-run 预览;推荐零参数提交,由受信层重放预览内容)→ 受信 +argv 重组(注入 --as user / uuid / --yes)→ 执行与错误翻译。 + +约定:所有"尚未产生外部副作用"的失败一律用主错误码 +``INVALID_ARGUMENTS``(子码放 ``error.subcode``),这样 invoker 的 +``_failure_was_not_sent`` 会释放防重放声明,允许修正后重试;执行后 +的失败保留具体错误码,声明被保守地保留。 +""" + +from __future__ import annotations + +import hashlib +import json +import re +import threading +import time +from typing import Any + +from cryptography.fernet import InvalidToken +from sqlmodel import select + +from app.channels.crypto import decrypt_channel_secret +from app.config import get_settings +from app.db.models import ChannelBinding, ChatSession, HarnessInvocationRecord +from app.lark_cli import background, policy +from app.lark_cli.provision import LarkCliProvisionError, ensure_lark_cli +from app.lark_cli.runner import ( + LarkCliRunError, + configured_app_ids, + ensure_configured, + run_lark_cli, + user_home_dir, +) + +TOOL_NAME = "lark_cli" + +LARK_CLI_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "args": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 32, + "description": ( + "lark-cli 的 argv(不含二进制名),例如 " + '["approval","approvals","search","--data","{\\"keyword\\":\\"报销\\"}"]。' + ), + }, + "confirmed_form_digest": { + "type": "string", + "description": ( + "仅当真实提交(approval instances create 且无 --dry-run)显式携带 " + "--data 时需要:原样回传 dry-run 预览结果里的 submission_digest。" + "推荐做法是提交时不带 --data 也不带本字段,系统会自动重放本会话" + "最近一次 dry-run 预览(即用户确认过)的内容。" + ), + }, + }, + "required": ["args"], + "additionalProperties": False, +} + +LARK_CLI_DESCRIPTION = ( + "运行飞书官方 lark-cli(读宽写严:只读命令支持官方原生参数直接调用," + "如 approval approvals get --approval-code ;未登记的只读命令也" + "会按 CLI 自身的 Risk 分级自动放行;写命令仅限白名单:auth 登录/登出、" + "approval 实例 dry-run 预览与创建、config init 应用配置)。用户要换" + "飞书账号时:auth logout 清除当前登录态,再重新走设备码登录流程。若报应用未配置,可在对话中完成:用户提供" + "现有应用凭据则 config init --app-id --app-secret ;或 " + "config init --new 现场创建新应用(把返回的 verification_url 发给用户," + "完成后重复调用查询进度)。登录走设备码分回合流程:auth login " + "--scope ... --no-wait --json 拿到 verification_url 先发给用户,用户" + "确认授权后再用 --device-code 收尾。真实提交前必须先 --dry-run 预览、" + "把表单逐字段展示给用户并获得明确同意;确认后在提交节点直接调用 " + "approval instances create(不带 --data),系统会自动重放已确认的预览" + "内容并注入 --yes 与幂等 uuid,禁止自带。" +) + + +def invoke_lark_cli( + db: Any, + *, + tenant_id: str, + session: ChatSession, + task_frame_id: str, + agent_id: str | None, + arguments: dict[str, Any], + active_skill: Any | None = None, + active_step_id: str | None = None, +) -> dict[str, Any]: + if not get_settings().lark_cli_enabled: + return _precondition_failure( + "LARK_CLI_DISABLED", "lark-cli 集成未启用(settings.lark_cli_enabled)。" + ) + raw_args = arguments.get("args") + if not isinstance(raw_args, list) or not raw_args: + return _precondition_failure("LARK_CLI_ARGS_REQUIRED", "args 必须是非空字符串数组。") + try: + resolved = policy.resolve([str(item) for item in raw_args]) + except policy.LarkCliPolicyError as exc: + return _precondition_failure(exc.code, exc.message) + + user_key = str(session.user_id or "").strip() + if not user_key: + return _precondition_failure( + "LARK_CLI_USER_UNRESOLVED", + "当前会话没有可用的 StaffDeck 用户身份,无法定位该用户的飞书登录态。", + ) + credential_error, app_id, app_secret = _resolve_app_credentials(db, tenant_id, agent_id) + if credential_error is not None: + return credential_error + + argv = list(resolved.argv) + # 审批域命令只允许 user 身份(policy 已锁死取值),由受信代码统一注入, + # 不依赖模型记得传 --as user(CLI 侧缺省会解析成 bot 而报错)。 + if argv[0] == "approval" and not resolved.is_help: + argv = _ensure_flag_pair(argv, "--as", "user") + stashed_digest: str | None = None + stashed_body: dict[str, Any] | None = None + if resolved.is_help: + pass # --help 纯只读,跳过所有提交闸。 + elif resolved.rule.action == "gated_write" and not resolved.is_dry_run: + if not _step_authorizes_submission(active_skill, active_step_id): + # 真实案例:模型收到旧版拒绝文案后误判"任务失败",在提交节点 + # 空手 finish(failed)。消息必须重定向到正确动作,而不是只说不行。 + return _precondition_failure( + "LARK_CLI_SUBMIT_REQUIRES_SOP", + "当前步骤不允许执行真实提交(只有 capability_refs.tool_ids 显式" + "包含 lark_cli 的 SOP 提交节点可以)。这不是任务失败:请立即" + "结束当前步骤并给出 next_step_id 进入提交节点,进入提交节点后" + "第一时间直接调用 approval instances create(不带 --data)," + "系统会自动提交用户确认过的预览内容。登录、查询与 --dry-run " + "预览不受此限制。", + ) + try: + gate_error, argv = _apply_submission_gate( + db, + session_id=session.id, + task_frame_id=task_frame_id, + arguments=arguments, + resolved=resolved, + ) + except policy.LarkCliPolicyError as exc: + return _precondition_failure(exc.code, exc.message) + if gate_error is not None: + return gate_error + elif resolved.rule.action == "gated_write" and resolved.is_dry_run: + try: + stashed_digest = policy.submission_digest(resolved.data_json) + # 规范化提交体随预览结果落库(response_cache_json),供提交节点 + # 跨帧零参数重放——模型在新帧里重组表单曾多次自造控件 id。 + body = policy.canonical_submission_body(resolved.data_json) + stashed_body = body if isinstance(body, dict) else None + if resolved.data_json is not None: + # 预览与提交走同一线上格式(form 序列化为 JSON 字符串), + # 保证 dry-run 展示的就是真正会发出去的请求体。 + argv = _replace_flag_value( + argv, + "--data", + json.dumps( + policy.wire_submission_data(resolved.data_json), + ensure_ascii=False, + separators=(",", ":"), + ), + ) + except policy.LarkCliPolicyError as exc: + return _precondition_failure(exc.code, exc.message) + argv = _ensure_flag_pair(argv, "--as", "user") + + if argv[0] == "auth" and "--json" not in argv: + argv.append("--json") + + try: + binary = ensure_lark_cli() + except LarkCliProvisionError as exc: + return _precondition_failure("LARK_CLI_NOT_INSTALLED", str(exc)) + home = user_home_dir(tenant_id, user_key) + if resolved.rule.prefix == ("config", "init") and not resolved.is_help: + return _handle_config_init(binary, home, argv) + if resolved.needs_risk_probe and not resolved.is_help: + risk, help_text = _probe_command_risk(binary, home, resolved.rule.prefix) + if risk != "read": + detail = ( + f"CLI 将其标记为 {risk}(写操作需在策略表显式登记后才可用)" + if risk + else "无法确认其为只读命令" + ) + return _precondition_failure( + "LARK_CLI_COMMAND_BLOCKED", + f"命令 {' '.join(resolved.rule.prefix)} 未在策略表登记,且{detail};" + "只读(Risk: read)命令可直接使用。请如实告知用户此操作暂不" + "支持(可由管理员评估后开放);不要建议用户在本地终端自行执行" + "——飞书登录态保存在系统托管的隔离环境中,用户终端里的 " + "lark-cli 既看不到它也操作不了它。", + ) + if "--as" in help_text and "--as" not in argv: + argv = [*argv, "--as", "user"] + try: + if app_id and app_secret: + ensure_configured(binary, home, app_id=app_id, app_secret=app_secret) + elif not configured_app_ids(home): + # 三级凭据(settings / 渠道绑定 / 对话内配置)全部缺失。 + return _precondition_failure( + "LARK_CLI_APP_NOT_CONFIGURED", + "尚无飞书应用凭据,可直接在对话中完成配置:① 用户已有应用:" + "请用户提供 app_id 与 app_secret(开放平台应用详情页可查)," + "然后调用 config init --app-id --app-secret ;" + "② 现场创建:调用 config init --new,把返回的 verification_url " + "发给用户在浏览器完成创建,之后重复同命令查询进度。", + ) + if resolved.is_dry_run and isinstance(stashed_body, dict): + # CLI 的 --dry-run 不打服务端、零校验:编造的控件结构会一路 + # 通过预览、被用户确认,直到真实提交才暴雷。受信层拿定义原文 + # 在预览前做结构校验,把这类错误拦在用户确认之前。 + mismatch = _validate_form_structure(binary, home, stashed_body) + if mismatch is not None: + return mismatch + result = run_lark_cli( + binary, + home, + argv, + timeout_seconds=resolved.rule.timeout_seconds, + redact=(app_secret,), + ) + except LarkCliRunError as exc: + return { + "success": False, + "error": {"code": exc.code, "message": exc.message, "retryable": True}, + } + finally: + app_secret = "" + + return _translate(result, resolved, stashed_digest, stashed_body) + + +def _resolve_app_credentials( + db: Any, tenant_id: str, agent_id: str | None +) -> tuple[dict[str, Any] | None, str, str]: + """复用已激活的飞书渠道绑定里的应用凭据(若有)。 + + 返回 (错误或 None, app_id, app_secret 明文)。没有绑定时返回空凭据而非 + 错误——常态是用户 HOME 已通过对话内 ``config init`` 配置过,是否可用由 + 调用方结合 HOME 状态判定。用户级身份始终来自 ``auth login`` 设备码 + 流程;这里只解决"应用载体"从哪来。 + """ + + binding = _feishu_binding(db, tenant_id, agent_id) + if binding is None or not binding.credentials_enc: + return None, "", "" + app_id = str((binding.config_json or {}).get("app_id") or "").strip() + if not app_id: + return None, "", "" + try: + app_secret = decrypt_channel_secret(binding.credentials_enc) + except (InvalidToken, ValueError, TypeError): + return ( + _precondition_failure( + "LARK_CLI_CREDENTIALS_UNREADABLE", + "飞书应用凭据解密失败,请检查 CHANNEL_SECRET/APP_SECRET 配置。", + ), + "", + "", + ) + return None, app_id, app_secret + + +_RISK_CACHE: dict[tuple[str, tuple[str, ...]], tuple[str | None, str]] = {} +_RISK_CACHE_LOCK = threading.Lock() +_RISK_PATTERN = re.compile(r"(?im)^\s*Risk:\s*(read|write|high-risk-write)\b") + + +def _probe_command_risk( + binary: Any, home: Any, prefix: tuple[str, ...] +) -> tuple[str | None, str]: + """未登记命令的动态裁决:跑一次 ``--help`` 读 CLI 自带的 Risk 分级。 + + 返回 (risk 或 None, help 文本)。结果按二进制路径+前缀缓存;解析不到 + Risk 行时返回 None(调用方按拒绝处理,保守默认)。 + """ + + key = (str(binary), tuple(prefix)) + with _RISK_CACHE_LOCK: + if key in _RISK_CACHE: + return _RISK_CACHE[key] + try: + result = run_lark_cli( + binary, home, [*prefix, "--help"], timeout_seconds=20.0 + ) + except LarkCliRunError: + return None, "" + text = f"{result.stdout}\n{result.stderr}" + match = _RISK_PATTERN.search(text) + risk = match.group(1).lower() if match else None + with _RISK_CACHE_LOCK: + _RISK_CACHE[key] = (risk, text) + return risk, text + + +_DEFINITION_CACHE: dict[tuple[str, str], tuple[float, list[Any]]] = {} +_DEFINITION_CACHE_LOCK = threading.Lock() +_DEFINITION_TTL_SECONDS = 600.0 + + +def _validate_form_structure( + binary: Any, home: Any, body: dict[str, Any] +) -> dict[str, Any] | None: + """dry-run 前的定义结构校验;返回错误结果或 None(通过/无法校验)。 + + 定义获取失败(网络等)时静默跳过,不阻塞预览——校验是增强而非门槛, + 最终裁决权在服务端。 + """ + + approval_code = str(body.get("approval_code") or "").strip() + form = body.get("form") + if not approval_code or not isinstance(form, list): + return None + widgets = _approval_definition_widgets(binary, home, approval_code) + if widgets is None: + return None + problems = policy.validate_form_against_definition(form, widgets) + if not problems: + return None + return _precondition_failure( + "LARK_CLI_FORM_MISMATCH", + "表单与审批定义不一致,已在预览前拦截(按下述修正后重新 --dry-run):\n- " + + "\n- ".join(problems), + ) + + +def _approval_definition_widgets( + binary: Any, home: Any, approval_code: str +) -> list[Any] | None: + """获取并缓存审批定义的控件树(受信只读调用);失败返回 None。""" + + key = (str(home), approval_code) + now = time.monotonic() + with _DEFINITION_CACHE_LOCK: + cached = _DEFINITION_CACHE.get(key) + if cached is not None and now - cached[0] < _DEFINITION_TTL_SECONDS: + return cached[1] + try: + result = run_lark_cli( + binary, + home, + [ + "approval", + "approvals", + "get", + "--approval-code", + approval_code, + "--as", + "user", + "--json", + ], + timeout_seconds=30.0, + ) + except LarkCliRunError: + return None + envelope = result.envelope if isinstance(result.envelope, dict) else {} + data = envelope.get("data") if isinstance(envelope.get("data"), dict) else envelope + form = data.get("form") if isinstance(data, dict) else None + if isinstance(form, str): + try: + widgets = json.loads(form) + except json.JSONDecodeError: + return None + else: + widgets = form + if not isinstance(widgets, list) or not widgets: + return None + with _DEFINITION_CACHE_LOCK: + _DEFINITION_CACHE[key] = (now, widgets) + return widgets + + +def _handle_config_init(binary: Any, home: Any, argv: list[str]) -> dict[str, Any]: + """对话内应用配置的受信路由:不透传 argv,改走加固过的执行路径。 + + 两种形态:``--new`` 走后台进程(阻塞式浏览器创建流程,URL 接力给 + 用户);``--app-id`` + ``--app-secret`` 走幂等的 ``ensure_configured`` + (secret 截下后经 stdin 注入 CLI 并全程脱敏输出——用户在对话里提供 + 的 secret 无法从聊天记录里抹掉,但至少不进 CLI argv 与执行结果)。 + """ + + if "--new" in argv: + status = background.config_init_new_status(binary, home) + if status.get("status") == "failed": + return { + "success": False, + "error": { + "code": "LARK_CLI_CONFIG_INIT_FAILED", + "message": str(status.get("detail") or "应用创建失败。"), + "retryable": True, + }, + } + return {"success": True, "data": status} + app_id = str(_flag_value(argv, "--app-id") or "").strip() + app_secret = str(_flag_value(argv, "--app-secret") or "").strip() + if not app_id or not app_secret: + return _precondition_failure( + "LARK_CLI_CONFIG_ARGS_REQUIRED", + "config init 需要 --app-id 与 --app-secret(或使用 --new 现场创建应用)。", + ) + brand = str(_flag_value(argv, "--brand") or "feishu").strip() or "feishu" + try: + ensure_configured(binary, home, app_id=app_id, app_secret=app_secret, brand=brand) + except LarkCliRunError as exc: + return { + "success": False, + "error": {"code": exc.code, "message": exc.message, "retryable": True}, + } + finally: + app_secret = "" + return { + "success": True, + "data": { + "configured_app_ids": sorted(configured_app_ids(home)), + "next_step": ( + "应用凭据已配置。继续调用 auth status 检查用户登录态," + "未登录则走设备码登录流程。" + ), + }, + } + + +def _flag_value(argv: list[str], flag: str) -> str | None: + for index, token in enumerate(argv[:-1]): + if token == flag: + return argv[index + 1] + return None + + +def _step_authorizes_submission(active_skill: Any | None, active_step_id: str | None) -> bool: + """结构性流程闸:提交动作必须被当前 SOP 节点显式授权。 + + 这保证"展示表单 → 用户确认"的暂停节点无法被绕过——conversation 帧 + 与未引用 lark_cli 的 SOP 节点里,模型即便持有正确摘要也无法提交。 + """ + + if active_skill is None: + return False + from app.core.task_request_compiler import current_step_capability_refs + + refs = current_step_capability_refs(active_skill, active_step_id) + tool_ids = {str(item).strip() for item in refs.get("tool_ids") or []} + return TOOL_NAME in tool_ids or "builtin.lark_cli" in tool_ids + + +def _apply_submission_gate( + db: Any, + *, + session_id: str, + task_frame_id: str, + arguments: dict[str, Any], + resolved: policy.ResolvedCommand, +) -> tuple[dict[str, Any] | None, list[str]]: + """真实提交的确认闸;返回 (错误或 None, 重组后的 argv)。 + + 两条路径:① 推荐——不带 --data 的零参数提交,受信层重放本会话最近 + 一次 dry-run 预览的规范化内容(跨帧后模型无需重组表单,从根上消灭 + 自造控件 id / 格式回退类错误;提交内容与用户看到的预览按构造一致)。 + ② 显式携带 --data 时维持原语义:摘要必须与本会话内某条预览记录一致。 + """ + + argv = list(resolved.argv) + confirmed = str(arguments.get("confirmed_form_digest") or "").strip() + digest = policy.submission_digest(resolved.data_json) + if digest: + if not confirmed: + return ( + _precondition_failure( + "LARK_CLI_CONFIRMATION_REQUIRED", + "显式携带 --data 提交时缺少 confirmed_form_digest。更简单的" + "做法:去掉 --data 与本字段直接调用,系统会自动提交本会话" + "最近一次 dry-run 预览(用户已确认)的内容。", + ), + argv, + ) + if confirmed != digest: + return ( + _precondition_failure( + "LARK_CLI_PREVIEW_DIGEST_MISMATCH", + "confirmed_form_digest 与本次提交内容不一致:提交体被修改过," + "必须重新 dry-run 预览并让用户确认新内容。", + ), + argv, + ) + if _find_preview_data(db, session_id, digest=digest) is None: + return ( + _precondition_failure( + "LARK_CLI_PREVIEW_NOT_FOUND", + "本会话内没有该内容的 dry-run 预览记录:不允许提交未经" + "用户预览确认的内容。", + ), + argv, + ) + data_json = policy.wire_submission_data(dict(resolved.data_json or {})) + else: + cached = _find_preview_data(db, session_id, digest=confirmed or None) + canonical = (cached or {}).get("canonical_data") if cached else None + if not isinstance(canonical, dict): + return ( + _precondition_failure( + "LARK_CLI_PREVIEW_NOT_FOUND", + "本会话内没有可重放的 dry-run 预览记录:请先组装 --data 并" + "执行 --dry-run 预览、向用户逐字段展示并获得明确确认," + "然后再提交。", + ), + argv, + ) + digest = str(cached.get("submission_digest") or "") + data_json = policy.wire_submission_data(dict(canonical)) + data_json["uuid"] = _submission_uuid(task_frame_id, digest) + argv = _replace_flag_value( + argv, "--data", json.dumps(data_json, ensure_ascii=False, separators=(",", ":")) + ) + argv = _ensure_flag_pair(argv, "--as", "user") + argv.append("--yes") + return None, argv + + +def _find_preview_data( + db: Any, session_id: str, digest: str | None = None +) -> dict[str, Any] | None: + """按时间倒序找本会话内最近一条 dry-run 预览的缓存数据。 + + 指定 digest 时要求摘要一致(显式 --data 路径);未指定时取最近一条 + (零参数重放路径——正常流程里进入提交节点时它就是用户刚确认的那次)。 + """ + + rows = db.exec( + select(HarnessInvocationRecord) + .where( + HarnessInvocationRecord.session_id == session_id, + HarnessInvocationRecord.tool_name == TOOL_NAME, + HarnessInvocationRecord.status == "completed", + ) + .order_by(HarnessInvocationRecord.started_at.desc()) # type: ignore[attr-defined] + ).all() + for row in rows: + cached = row.response_cache_json or {} + data = cached.get("data") + if not isinstance(data, dict) or not data.get("submission_digest"): + continue + if digest is not None and data.get("submission_digest") != digest: + continue + return data + return None + + +def _translate( + result: Any, + resolved: policy.ResolvedCommand, + stashed_digest: str | None, + stashed_body: dict[str, Any] | None = None, +) -> dict[str, Any]: + envelope = result.envelope + if result.exit_code == 10: + # 不应发生:--yes 由本模块注入。出现说明策略表与 CLI 风险面失配。 + return { + "success": False, + "error": { + "code": "LARK_CLI_UNEXPECTED_CONFIRMATION", + "message": "CLI 要求确认标志但系统未注入——请反馈给管理员检查策略表。", + "retryable": False, + }, + } + # 部分命令(如 auth status)的信封没有 ok 字段:显式 ok 优先, + # 否则以「退出码 0 且无 error 字段」为成功。 + if isinstance(envelope, dict): + if "ok" in envelope: + ok = bool(envelope.get("ok")) + else: + ok = result.exit_code == 0 and "error" not in envelope + else: + ok = result.exit_code == 0 + if ok: + data: dict[str, Any] = {"envelope": envelope if envelope is not None else {}} + if envelope is None and result.stdout.strip(): + data["stdout"] = result.stdout.strip()[:4_000] + if stashed_digest: + data["submission_digest"] = stashed_digest + if stashed_body is not None: + # 落库供提交节点零参数重放(response_cache_json 持久化的就是 + # 本返回值);对模型也是一份"已预览内容"的权威快照。 + data["canonical_data"] = stashed_body + data["next_step"] = ( + "向用户逐字段展示上述表单内容;用户明确确认后,在提交节点直接" + "调用 approval instances create(不带 --data),系统会自动提交" + "本次预览的内容。" + ) + return {"success": True, "data": data} + error = envelope.get("error") if isinstance(envelope, dict) else None + if isinstance(error, dict): + message = str(error.get("message") or "lark-cli 返回错误。") + hint = str(error.get("hint") or "").strip() + if hint: + message = f"{message}(提示:{hint})" + subtype = str(error.get("subtype") or error.get("type") or "error") + code = "LARK_CLI_" + subtype.upper() + message += _form_error_guidance(message, subtype) + else: + tail = (result.stderr or result.stdout).strip()[-800:] + message = f"lark-cli 执行失败(exit {result.exit_code}):{tail}" + code = "LARK_CLI_EXEC_FAILED" + message += _auth_scopes_error_guidance(resolved.rule.prefix) + retryable = resolved.rule.action != "gated_write" or resolved.is_dry_run + return { + "success": False, + "error": {"code": code, "message": message, "retryable": retryable}, + } + + +def _auth_scopes_error_guidance(prefix: tuple[str, ...]) -> str: + """auth scopes 失败的确定性纠偏(真实案例驱动)。 + + 该诊断命令自身需要应用管理类权限(admin:app.info:readonly 等),普通 + 审批应用未申请时必然失败;错误原文"app has not applied for the + required scope(s)"曾被模型误读为"应用没有审批权限、流程无法继续", + 未调一次 auth login 就 finish(failed),整条 SOP 被暂停。指引必须把 + 错误重定向到正确动作,而不是任由模型自行归因。 + """ + + if prefix != ("auth", "scopes"): + return "" + return ( + "(说明:auth scopes 是诊断命令,本身需要应用管理类权限,普通审批" + "应用未申请时必然失败;此失败只关乎该诊断命令自身,与审批流程所需" + "权限无关,更不代表任务失败。请不要依赖此命令:用户未登录时直接" + "执行 auth login --scope <所需审批 scope> --no-wait 发起设备码" + "授权即可。)" + ) + + +def _form_error_guidance(message: str, subtype: str) -> str: + """把服务端拒绝表单的高频错误翻译成可执行的修正指引(真实案例驱动)。""" + + if "未找到表单控件" in message or "widget" in message.lower(): + return ( + "(控件 id、type 与嵌套结构必须逐一取自 approvals get 返回的 form 定义," + "不得自造;复合控件(如 leaveGroupV2)的子控件必须嵌套在该复合控件的 " + "value 数组内整体提交,不得平铺到 form 顶层。请重新获取定义详情、按其" + "结构重组 form,然后重新 --dry-run 预览后再提交。)" + ) + if "RFC3339" in message: + return ( + "(date 控件的值需为 RFC3339 格式;系统会自动把 YYYY-MM-DD[ HH:MM[:SS]] " + "等常见写法补全时区,仍报此错说明该值不是可解析的日期字符串——请检查" + "对应控件的取值,修正后重新 --dry-run 预览再提交。)" + ) + if "form" in message and ("invalid" in subtype.lower() or "Invalid parameter" in message): + return ( + "(表单被服务端拒绝的常见原因:radioV2/select 的 value 必须用 " + "approvals get 返回的选项 key 而非选项文字;请假类定义若含 " + "leaveGroupV2 复合控件需按其结构整体组装。修正后必须重新 " + "--dry-run 预览并再次征得用户确认。)" + ) + return "" + + +def _submission_uuid(task_frame_id: str, digest: str) -> str: + seed = f"{task_frame_id}:{digest}".encode() + return hashlib.sha256(seed).hexdigest()[:32] + + +def _feishu_binding(db: Any, tenant_id: str, agent_id: str | None) -> ChannelBinding | None: + rows = db.exec( + select(ChannelBinding).where( + ChannelBinding.tenant_id == tenant_id, + ChannelBinding.channel == "feishu", + ChannelBinding.status == "active", + ) + ).all() + if not rows: + return None + if agent_id: + for row in rows: + if row.agent_id == agent_id: + return row + return rows[0] + + +def _ensure_flag_pair(argv: list[str], flag: str, value: str) -> list[str]: + if flag in argv: + return argv + return [*argv, flag, value] + + +def _replace_flag_value(argv: list[str], flag: str, value: str) -> list[str]: + result = list(argv) + for index, token in enumerate(result[:-1]): + if token == flag: + result[index + 1] = value + return result + return [*result, flag, value] + + +def _precondition_failure(subcode: str, message: str) -> dict[str, Any]: + # 主码固定 INVALID_ARGUMENTS:见模块 docstring 的防重放声明约定。 + return { + "success": False, + "error": { + "code": "INVALID_ARGUMENTS", + "subcode": subcode, + "message": message, + "retryable": True, + }, + } diff --git a/backend/tests/test_harness_v2.py b/backend/tests/test_harness_v2.py index 0147fc87c..fea31be4e 100644 --- a/backend/tests/test_harness_v2.py +++ b/backend/tests/test_harness_v2.py @@ -4,7 +4,7 @@ import json import sys from copy import deepcopy -from datetime import timedelta +from datetime import datetime, timedelta from types import SimpleNamespace import pytest @@ -3298,6 +3298,57 @@ def test_invalid_action_protocol_failure_keeps_sop_loop_recoverable() -> None: assert _is_recoverable_action_protocol_failure(business_failure) is False +def test_resumed_agent_sees_latest_user_reply(monkeypatch) -> None: + """从暂停恢复时,用户的新回复必须出现在内层对话记录里。""" + + actions = iter( + [ + { + "action": "finish", + "status": "completed", + "reply_fragment": "好的", + "task_summary": "完成", + }, + ] + ) + payloads: list[dict[str, object]] = [] + + class FakeLLMClient: + def __init__(self, _model_config: ModelConfig): + pass + + def generate_json( + self, _system_prompt: str, payload: dict[str, object] + ) -> dict[str, object]: + payloads.append(deepcopy(payload)) + return next(actions) + + monkeypatch.setattr(harness_agent_module, "LLMClient", FakeLLMClient) + requirement = TaskRequirement( + task_frame_id="task-resume", + kind="sop", + goal="继续", + source_user_message="提交", + capability_manifest=CapabilityManifest(), + ) + checkpoint = { + "task_frame_id": "task-resume", + "step_id": "", + "transcript": [ + {"role": "assistant", "action": "finish", "status": "awaiting_user"}, + ], + } + HarnessTaskAgent().run( + requirement, + _model_config(), + lambda name, arguments: {"success": True}, + max_actions=2, + checkpoint=checkpoint, + ) + transcript = payloads[0]["harness_transcript"] + assert {"role": "user", "content": "提交"} in transcript + + def test_failed_following_sop_step_keeps_completed_checkpoint_reply() -> None: completed = TaskExecutionResult( task_frame_id="task-price-compare", @@ -3321,7 +3372,10 @@ def test_failed_following_sop_step_keeps_completed_checkpoint_reply() -> None: ) assert deferred.status == "action_budget" - assert deferred.reply_fragment == completed.reply_fragment + # 保留已完成节点的回复,但必须说明后续步骤已暂停排队(真实案例: + # 前瞻式回复"将进入正式提交步骤"曾让用户误以为会自动继续)。 + assert deferred.reply_fragment.startswith(completed.reply_fragment) + assert "回复任意消息即可继续" in deferred.reply_fragment assert deferred.next_step_id is None assert deferred.error == failed.error assert deferred.action_count == 1 @@ -4037,6 +4091,83 @@ def invoke_tool(name: str, arguments: dict[str, object]) -> dict[str, object]: assert transcript[-1]["result"]["error"]["code"] == ("REQUIRED_CAPABILITY_NOT_INVOKED") +def test_harness_agent_blocks_failed_finish_without_attempting_required_tool( + monkeypatch, +) -> None: + """模型未在本节点尝试强制能力就 finish(failed) 时打回一次(真实案例: + 提交节点带着上一步骤的报错直接宣告失败);实际尝试后允许如实失败。""" + + actions = iter( + [ + { + "action": "finish", + "status": "failed", + "reply_fragment": "提交被拒绝,任务失败。", + "task_summary": "未尝试就失败", + }, + { + "action": "tool", + "tool_name": "lark_cli", + "arguments": {"args": ["approval", "instances", "create"]}, + }, + { + "action": "finish", + "status": "failed", + "reply_fragment": "提交经尝试后仍失败。", + "task_summary": "尝试后失败", + }, + ] + ) + payloads: list[dict[str, object]] = [] + + class FakeLLMClient: + def __init__(self, _model_config: ModelConfig): + pass + + def generate_json( + self, _system_prompt: str, payload: dict[str, object] + ) -> dict[str, object]: + payloads.append(deepcopy(payload)) + return next(actions) + + calls: list[str] = [] + + def invoke_tool(name: str, arguments: dict[str, object]) -> dict[str, object]: + calls.append(name) + return {"success": False, "error": {"code": "SERVER_REJECTED"}} + + monkeypatch.setattr(harness_agent_module, "LLMClient", FakeLLMClient) + requirement = TaskRequirement( + task_frame_id="task-submit-node", + kind="sop", + goal="完成提交节点", + required_capability_names=["lark_cli"], + capability_manifest=CapabilityManifest( + available=[ + CapabilityDescriptor( + capability_id="builtin.lark_cli", + name="lark_cli", + kind="internal", + ) + ] + ), + ) + + result = HarnessTaskAgent().run( + requirement, + _model_config(), + invoke_tool, + max_actions=5, + ) + + # 第一次 failed finish 被打回并要求实际调用;尝试失败后允许如实失败。 + assert calls == ["lark_cli"] + assert result.status == "failed" + assert result.reply_fragment == "提交经尝试后仍失败。" + transcript = payloads[1]["harness_transcript"] + assert transcript[-1]["result"]["error"]["code"] == "REQUIRED_CAPABILITY_NOT_ATTEMPTED" + + def test_harness_agent_requires_the_configured_knowledge_base(monkeypatch) -> None: actions = iter( [ @@ -5140,3 +5271,32 @@ def test_agent_loop_transcript_compacts_old_tool_payloads_but_keeps_skill_instru assert "content" not in old_read["result"]["data"] assert old_read["result"]["data"]["continuation_token"] == "next" assert old_read["result"]["history_receipt"]["omitted_chars"] > 20_000 + + +def test_current_time_uses_client_timezone_with_safe_fallback() -> None: + """注入的时间必须是用户所在时区:错误时区的时间比不注入更危险 + (看起来权威,模型不会质疑)。非法时区回退服务端本地时区而非报错。""" + + from app.core.task_request_compiler import _current_time_text + + shanghai = _current_time_text("Asia/Shanghai") + honolulu = _current_time_text("Pacific/Honolulu") + assert "+08:00" in shanghai + assert "-10:00" in honolulu + # 同一时刻的两个时区可能落在不同日期——这正是必须按用户时区注入的原因。 + assert shanghai[:10] >= honolulu[:10] + + for bad in ("", " ", "Not/AZone", "…"): + text = _current_time_text(bad) + assert datetime.fromisoformat(text.split("(")[0]).tzinfo is not None, bad + + +def test_compile_threads_client_timezone_into_requirement() -> None: + requirement = TaskRequestCompiler().compile( + PlannedTaskFrame(task_id="task-tz", kind="conversation"), + ChatSession(id="s-tz", tenant_id="t1"), + None, + CapabilityManifest(), + client_timezone="Asia/Shanghai", + ) + assert "+08:00" in requirement.current_time diff --git a/backend/tests/test_lark_cli.py b/backend/tests/test_lark_cli.py new file mode 100644 index 000000000..ba33166af --- /dev/null +++ b/backend/tests/test_lark_cli.py @@ -0,0 +1,1277 @@ +from __future__ import annotations + +import json +import os +import stat +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + +from app.channels.crypto import encrypt_channel_secret +from app.config import Settings +from app.core.capability_manifest import ( + RESERVED_HARNESS_CAPABILITY_NAMES, + _lark_cli_descriptor, +) +from app.db.models import ChannelBinding, ChatSession, HarnessInvocationRecord, Skill +from app.lark_cli import policy, runner, service + + +def _submit_skill() -> Skill: + return Skill( + tenant_id="t1", + skill_id="sop-lark", + name="飞书审批提交", + content_json={ + "start_node_id": "n_submit", + "nodes": [ + { + "node_id": "n_submit", + "name": "提交审批", + "instruction": "确认后提交", + "capability_refs": {"tool_ids": ["lark_cli"]}, + } + ], + "edges": [], + }, + ) + + +# --------------------------------------------------------------------------- +# policy +# --------------------------------------------------------------------------- + + +def test_policy_allows_read_and_marks_dry_run() -> None: + resolved = policy.resolve( + ["approval", "approvals", "search", "--data", '{"keyword":"报销"}'] + ) + assert resolved.rule.action == "read" + assert not resolved.is_side_effect_write + + dry = policy.resolve( + [ + "approval", + "instances", + "create", + "--data", + '{"approval_code":"A","form":"[]"}', + "--dry-run", + ] + ) + assert dry.rule.action == "gated_write" + assert dry.is_dry_run and not dry.is_side_effect_write + + +@pytest.mark.parametrize( + "argv,code", + [ + (["api", "GET", "/open-apis/x"], "LARK_CLI_COMMAND_BLOCKED"), + (["config", "show"], "LARK_CLI_COMMAND_BLOCKED"), + (["im", "messages", "send", "--yes"], "LARK_CLI_YES_NOT_ALLOWED"), + (["approval", "tasks", "query", "--data", "@/etc/passwd"], "LARK_CLI_FILE_REF_BLOCKED"), + (["approval", "approvals", "get", "--as", "bot"], "LARK_CLI_IDENTITY_BLOCKED"), + ( + ["approval", "instances", "create", "--data", "{}", "--yes"], + "LARK_CLI_YES_NOT_ALLOWED", + ), + ( + ["approval", "instances", "create", "--data", "@/etc/passwd"], + "LARK_CLI_FILE_REF_BLOCKED", + ), + (["approval", "instances", "create", "--data", "-"], "LARK_CLI_FILE_REF_BLOCKED"), + ( + ["approval", "instances", "create", "--data", '{"uuid":"mine"}'], + "LARK_CLI_UUID_NOT_ALLOWED", + ), + ( + ["approval", "instances", "create", "--data", "{}", "--as", "bot"], + "LARK_CLI_IDENTITY_BLOCKED", + ), + (["auth", "login", "--output", "x"], "LARK_CLI_FLAG_BLOCKED"), + (["auth", "qrcode", "--url", "https://x"], "LARK_CLI_COMMAND_BLOCKED"), + ], +) +def test_policy_blocks(argv: list[str], code: str) -> None: + with pytest.raises(policy.LarkCliPolicyError) as exc: + policy.resolve(argv) + assert exc.value.code == code + + +def test_policy_read_commands_accept_official_typed_flags() -> None: + """真实案例:官方正确用法 `approvals get --approval-code X` 曾被白名单 + 误拦,逼模型每个新对话都重复试错。读命令 flag 全放行。""" + + resolved = policy.resolve( + ["approval", "approvals", "get", "--approval-code", "X", "--locale", "zh-CN"] + ) + assert resolved.rule.action == "read" and not resolved.needs_risk_probe + assert not resolved.is_side_effect_write + + +def test_policy_unknown_commands_become_risk_probe_candidates() -> None: + """未登记命令交给动态 Risk 裁决;api/config 永不进入动态通道。""" + + for argv in ( + ["approval", "tasks", "approve", "--data", "{}"], + ["approval", "instances", "cancel", "--data", "{}"], + ["im", "messages", "list"], + ["contact", "users", "get", "--user-id", "u1"], + ): + resolved = policy.resolve(argv) + assert resolved.needs_risk_probe, argv + assert resolved.rule.action == "read" + with pytest.raises(policy.LarkCliPolicyError): + policy.resolve(["api", "POST", "/open-apis/x"]) + with pytest.raises(policy.LarkCliPolicyError): + policy.resolve(["config", "bind"]) + + +def test_submission_digest_is_stable_and_ignores_uuid() -> None: + base = {"approval_code": "A", "form": '[{"id":"w1","value":"x"}]'} + digest = policy.submission_digest(dict(base)) + assert digest == policy.submission_digest({**base, "uuid": "anything"}) + assert digest != policy.submission_digest({**base, "form": "[]"}) + + +def test_submission_digest_is_encoding_agnostic() -> None: + """form 传数组与传等价 JSON 字符串必须得到同一摘要(真实线上翻车案例)。""" + + as_array = {"approval_code": "A", "form": [{"id": "w1", "value": "x"}]} + as_string = {"approval_code": "A", "form": '[{"id": "w1", "value": "x"}]'} + assert policy.submission_digest(as_array) == policy.submission_digest(as_string) + + +def test_wire_submission_data_serializes_form_to_string() -> None: + wire = policy.wire_submission_data( + {"approval_code": "A", "form": [{"id": "w1", "value": "x"}]} + ) + assert isinstance(wire["form"], str) + assert json.loads(wire["form"]) == [{"id": "w1", "value": "x"}] + + +def test_wire_form_dates_normalized_to_rfc3339() -> None: + """定义详情把 date 控件展示为 YYYY-MM-DD hh:mm,模型照抄提交会被服务端以 + "start time format is not RFC3339" 拒绝(真实案例)——受信层必须兜底转换, + 含复合控件(leaveGroupV2)嵌套子控件。""" + + form = [ + { + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + {"id": "t", "type": "radioV2", "value": "7678264870298471375"}, + {"id": "s", "type": "date", "value": "2026-08-27 00:00"}, + {"id": "r", "type": "textarea", "value": "StaffDeck测试"}, + ], + } + ] + wire = policy.wire_submission_data({"approval_code": "A", "form": form}) + sent = json.loads(wire["form"])[0]["value"] + expected = ( + datetime.fromisoformat("2026-08-27 00:00").astimezone().isoformat(timespec="seconds") + ) + assert sent[1]["value"] == expected + assert "T" in expected and expected != "2026-08-27 00:00" + # 非日期控件不动;原始输入不被就地修改。 + assert sent[0]["value"] == "7678264870298471375" + assert sent[2]["value"] == "StaffDeck测试" + assert form[0]["value"][1]["value"] == "2026-08-27 00:00" + + +def test_wire_form_date_interval_normalized_and_unparseable_passthrough() -> None: + wire = policy.wire_submission_data( + { + "form": [ + { + "id": "d", + "type": "dateInterval", + "value": {"start": "2026-08-27", "end": "2026-08-28", "interval": 24.0}, + } + ] + } + ) + sent = json.loads(wire["form"])[0]["value"] + assert "T" in sent["start"] and "T" in sent["end"] + assert sent["interval"] == 24.0 + # 解析不了的值原样保留,交给服务端报错(错误制导会指回该控件)。 + wire2 = policy.wire_submission_data({"form": [{"id": "d", "type": "date", "value": "明天"}]}) + assert json.loads(wire2["form"])[0]["value"] == "明天" + + +def test_submission_digest_is_date_encoding_agnostic() -> None: + """dry-run 写 YYYY-MM-DD hh:mm、提交自修正为 RFC3339 时摘要必须一致, + 否则确认闸会误杀合法的日期格式自修正。""" + + rfc = datetime.fromisoformat("2026-08-27 09:00").astimezone().isoformat(timespec="seconds") + local_form = {"form": [{"id": "s", "type": "date", "value": "2026-08-27 09:00"}]} + rfc_form = {"form": [{"id": "s", "type": "date", "value": rfc}]} + assert policy.submission_digest(local_form) == policy.submission_digest(rfc_form) + + +def test_form_error_guidance_covers_observed_server_rejections() -> None: + """真实翻车过的两类服务端拒绝必须有针对性制导(消息里都不含 'form' 字样)。""" + + widget = service._form_error_guidance( + "审批定义中未找到表单控件, 请重新获取定义详情确认该控件是否存在. " + "index= 0, ID= widgetLeaveGroupType.", + "api", + ) + assert "复合控件" in widget and "平铺" in widget + rfc = service._form_error_guidance("start time format is not RFC3339", "api") + assert "RFC3339" in rfc and "--dry-run" in rfc + generic = service._form_error_guidance("Invalid parameter type in json: form", "invalid") + assert "选项 key" in generic + assert service._form_error_guidance("no scope", "permission_denied") == "" + + +def test_policy_allows_help_on_intermediate_prefix() -> None: + resolved = policy.resolve(["approval", "instances", "--help"]) + assert resolved.is_help and not resolved.is_side_effect_write + assert policy.resolve(["config", "--help"]).is_help + with pytest.raises(policy.LarkCliPolicyError): + policy.resolve(["api", "--help"]) + + +def test_task_requirement_carries_current_time() -> None: + from app.core.task_request_compiler import CapabilityManifest, TaskRequestCompiler + from app.session.session_schema import PlannedTaskFrame + + requirement = TaskRequestCompiler().compile( + PlannedTaskFrame(task_id="t", kind="conversation"), + ChatSession(id="s", tenant_id="t1"), + None, + CapabilityManifest(), + ) + assert requirement.current_time + assert "T" in requirement.current_time and "周" in requirement.current_time + + +def test_logical_signature_only_for_real_submission() -> None: + submit = ["approval", "instances", "create", "--data", '{"approval_code":"A"}'] + assert policy.logical_write_signature({"args": submit}) is not None + assert policy.logical_write_signature({"args": [*submit, "--dry-run"]}) is None + assert ( + policy.logical_write_signature( + {"args": ["auth", "login", "--device-code", "d"]} + ) + is None + ) + assert policy.logical_write_signature({"args": ["auth", "status"]}) is None + # --help 视为只读,不产生防重放签名。 + assert policy.logical_write_signature({"args": [*submit, "--help"]}) is None + + +# --------------------------------------------------------------------------- +# runner helpers +# --------------------------------------------------------------------------- + + +def test_parse_envelope_picks_last_json_object() -> None: + text = 'OK: saved\n{"first": true}\nnoise\n{"ok": false, "error": {"message": "x"}}' + parsed = runner._parse_envelope(text) + assert parsed == {"ok": False, "error": {"message": "x"}} + assert runner._parse_envelope("no json here") is None + + +def test_redact_removes_secret() -> None: + assert "s3cret" not in runner._redact("token s3cret leaked", ("s3cret",)) + + +def test_user_home_dir_isolated_per_user(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + home_a = runner.user_home_dir("tenant-1", "user/a") + home_b = runner.user_home_dir("tenant-1", "user-b") + assert home_a != home_b + assert home_a.is_dir() and home_b.is_dir() + if os.name == "posix": + assert stat.S_IMODE(home_a.stat().st_mode) == 0o700 + + +# --------------------------------------------------------------------------- +# service (fake binary end-to-end) +# --------------------------------------------------------------------------- + +_FAKE_BINARY = r'''#!/usr/bin/env python3 +import json, os, sys + +args = sys.argv[1:] +home = os.environ["HOME"] +if "--help" in args or "-h" in args: + # 模拟真实 CLI:help 文本携带 Risk 分级与 --as 说明(动态裁决依据)。 + joined = " ".join(args) + if "tasks approve" in joined or "instances cancel" in joined: + risk = "write" + else: + risk = "read" + print("Fake command help\n\nRisk: %s\n\nExecution:\n" + " --as string identity type: user | bot" % risk) + sys.exit(0) +if args[:2] == ["config", "init"] and "--new" in args: + # 真实 CLI:阻塞到用户在浏览器完成创建;先输出 verification URL。 + print("Open to create app: https://open.feishu.cn/app/create?ticket=abc123", flush=True) + import time + time.sleep(30) + sys.exit(0) +if args[:2] == ["config", "init"]: + secret = sys.stdin.read() + app_id = args[args.index("--app-id") + 1] + directory = os.path.join(home, ".lark-cli") + os.makedirs(directory, exist_ok=True) + with open(os.path.join(directory, "config.json"), "w") as fh: + json.dump({"apps": [{"appId": app_id, "brand": "feishu"}]}, fh) + print(json.dumps({"ok": True, "saw_secret": bool(secret)})) + sys.exit(0) +if args[:3] == ["approval", "approvals", "get"] and "LEAVE_DEF" in args: + # 供表单结构校验用的定义样例:复合控件子控件嵌套在 value 数组内, + # 与真实「请假」定义同构。 + definition = [ + {"id": "widgetLeaveGroupV2", "type": "leaveGroupV2", "required": True, "value": [ + {"id": "widgetLeaveGroupType", "type": "radioV2", + "option": [{"value": "7678264870298471375", "text": "事假"}, + {"value": "7678264870520835028", "text": "病假"}]}, + {"id": "widgetLeaveGroupStartTime", "type": "date"}, + {"id": "widgetLeaveGroupEndTime", "type": "date"}, + {"id": "widgetLeaveGroupReason", "type": "textarea"}, + ]}, + {"id": "widgetRemark", "type": "textarea"}, + ] + print(json.dumps({"ok": True, "data": {"form": json.dumps(definition)}})) + sys.exit(0) +if args[:2] == ["auth", "status"]: + # 真实 CLI 的 auth status 信封没有 ok 字段。 + print(json.dumps({ + "appId": "cli_test_app", + "identities": {"user": {"available": False, "status": "missing"}}, + "argv": args, + })) + sys.exit(0) +if any("trigger_error" in item for item in args): + print(json.dumps({ + "ok": False, + "error": {"type": "api", "subtype": "permission_denied", + "message": "no scope", "hint": "run auth login"}, + })) + sys.exit(0) +print(json.dumps({"ok": True, "argv": args, "env_ci": os.environ.get("CI", "")})) +''' + + +@pytest.fixture() +def db() -> Session: + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +@pytest.fixture() +def lark_env(monkeypatch, tmp_path: Path, db: Session): + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + binary = tmp_path / "fake-lark-cli" + binary.write_text(_FAKE_BINARY, encoding="utf-8") + binary.chmod(0o755) + monkeypatch.setattr(service, "ensure_lark_cli", lambda: binary) + monkeypatch.setattr( + service, "get_settings", lambda: Settings(lark_cli_enabled=True) + ) + chat = ChatSession(id="sess-1", tenant_id="t1", user_id="u1", agent_id="agent-1") + binding = ChannelBinding( + tenant_id="t1", + agent_id="agent-1", + channel="feishu", + status="active", + credentials_enc=encrypt_channel_secret("app-secret-value"), + config_json={"app_id": "cli_test_app"}, + ) + db.add(chat) + db.add(binding) + db.commit() + db.refresh(chat) + return {"db": db, "session": chat, "binary": binary} + + +@pytest.fixture() +def lark_env_unbound(monkeypatch, tmp_path: Path, db: Session): + """无渠道绑定、无 settings 凭据的环境:验证对话内配置路径。""" + + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + binary = tmp_path / "fake-lark-cli" + binary.write_text(_FAKE_BINARY, encoding="utf-8") + binary.chmod(0o755) + monkeypatch.setattr(service, "ensure_lark_cli", lambda: binary) + monkeypatch.setattr( + service, "get_settings", lambda: Settings(lark_cli_enabled=True) + ) + chat = ChatSession(id="sess-1", tenant_id="t1", user_id="u1", agent_id="agent-1") + db.add(chat) + db.commit() + db.refresh(chat) + return {"db": db, "session": chat, "binary": binary} + + +def _invoke(env, arguments, *, active_skill=None, active_step_id=None): + return service.invoke_lark_cli( + env["db"], + tenant_id="t1", + session=env["session"], + task_frame_id="frame-1", + agent_id="agent-1", + arguments=arguments, + active_skill=active_skill, + active_step_id=active_step_id, + ) + + +def test_service_disabled_returns_precondition(monkeypatch, db: Session) -> None: + monkeypatch.setattr( + service, "get_settings", lambda: Settings(lark_cli_enabled=False) + ) + chat = ChatSession(id="sess-0", tenant_id="t1", user_id="u1") + result = service.invoke_lark_cli( + db, + tenant_id="t1", + session=chat, + task_frame_id="frame-0", + agent_id=None, + arguments={"args": ["auth", "status"]}, + ) + assert result["success"] is False + assert result["error"]["code"] == "INVALID_ARGUMENTS" + assert result["error"]["subcode"] == "LARK_CLI_DISABLED" + + +def test_service_unconfigured_guides_conversational_setup(lark_env_unbound) -> None: + """无 settings 凭据、无渠道绑定、HOME 未配置 → 引导对话内配置而不是硬失败。""" + + result = _invoke(lark_env_unbound, {"args": ["auth", "status"]}) + assert result["success"] is False + assert result["error"]["subcode"] == "LARK_CLI_APP_NOT_CONFIGURED" + message = result["error"]["message"] + assert "--app-id" in message and "--new" in message + + +def test_service_conversational_config_works_without_binding( + lark_env_unbound, +) -> None: + """零配置路径:无 .env 凭据、无渠道绑定,对话内 config init 即可用。""" + + home = runner.user_home_dir("t1", "u1") + assert not runner.configured_app_ids(home) + + configured = _invoke( + lark_env_unbound, + { + "args": [ + "config", "init", "--app-id", "cli_chat_app", + "--app-secret", "chat-secret", + ] + }, + ) + assert configured["success"] is True + assert "cli_chat_app" in runner.configured_app_ids(home) + + # 配置后普通命令直接可用,全程未经过 settings 或渠道绑定。 + status = _invoke(lark_env_unbound, {"args": ["auth", "status"]}) + assert status["success"] is True + # secret 经 stdin 注入,不得出现在任何返回结果里。 + assert "chat-secret" not in json.dumps( + [configured, status], ensure_ascii=False + ) + + +def test_service_read_command_runs_and_configures_home(lark_env) -> None: + result = _invoke(lark_env, {"args": ["auth", "status"]}) + # auth status 信封没有 ok 字段,exit 0 且无 error 必须判成功。 + assert result["success"] is True + envelope = result["data"]["envelope"] + assert envelope["identities"]["user"]["available"] is False + assert "--json" in envelope["argv"] + home = runner.user_home_dir("t1", "u1") + assert "cli_test_app" in runner.configured_app_ids(home) + + +def test_service_injects_as_user_for_approval_commands(lark_env) -> None: + result = _invoke( + lark_env, + {"args": ["approval", "approvals", "search", "--data", '{"keyword":"请假"}']}, + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert argv[argv.index("--as") + 1] == "user" + + +def test_service_allows_help_and_auth_scopes(lark_env) -> None: + helped = _invoke(lark_env, {"args": ["auth", "login", "--help"]}) + assert helped["success"] is True + # --help 时不触发提交闸:即便是 gated_write 前缀也按只读处理。 + create_help = _invoke( + lark_env, {"args": ["approval", "instances", "create", "--help"]} + ) + assert create_help["success"] is True + scopes = _invoke(lark_env, {"args": ["auth", "scopes", "--json"]}) + assert scopes["success"] is True + + +def test_policy_blocks_auth_qrcode_with_redirect_guidance() -> None: + """真实案例:模型每次登录都先试 qrcode,白烧一轮。拒绝文案必须直接 + 指向正确动作(发 verification_url 链接),而非笼统的"未登记"。""" + + with pytest.raises(policy.LarkCliPolicyError) as exc: + policy.resolve(["auth", "qrcode", "--url", "https://x", "--output", "q.png"]) + assert "verification_url" in exc.value.message + + +def test_service_auth_scopes_failure_redirects_to_login(lark_env) -> None: + """真实案例:auth scopes 因自身缺管理权限而失败,模型误读为"应用没有 + 审批权限"并 finish(failed)。错误消息必须澄清失败归属并重定向到 auth login。""" + + result = _invoke(lark_env, {"args": ["auth", "scopes", "--trigger_error"]}) + assert result["success"] is False + message = result["error"]["message"] + assert "不代表任务失败" in message + assert "auth login" in message + + +def test_service_dry_run_returns_digest(lark_env) -> None: + data = '{"approval_code":"A","form":"[]"}' + result = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", data, "--dry-run"]}, + ) + assert result["success"] is True + digest = result["data"]["submission_digest"] + assert digest.startswith("sha256:") + assert "--as" in result["data"]["envelope"]["argv"] + # 规范化提交体随预览结果返回并落库,供提交节点零参数重放。 + assert result["data"]["canonical_data"] == {"approval_code": "A", "form": []} + + +def test_service_dry_run_normalizes_array_form_to_wire_string(lark_env) -> None: + data = '{"approval_code":"A","form":[{"id":"w1","value":"x"}]}' + result = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", data, "--dry-run"]}, + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + sent = json.loads(argv[argv.index("--data") + 1]) + assert isinstance(sent["form"], str) + + +def test_service_submit_accepts_cross_encoding_confirmation(lark_env) -> None: + """预览用数组 form、提交用字符串 form:语义相同必须通过摘要闸(回归)。""" + + array_data = '{"approval_code":"A","form":[{"id":"w1","value":"x"}]}' + string_data = json.dumps( + {"approval_code": "A", "form": '[{"id": "w1", "value": "x"}]'}, + ensure_ascii=False, + ) + digest = policy.submission_digest(json.loads(array_data)) + assert digest == policy.submission_digest(json.loads(string_data)) + lark_env["db"].add( + HarnessInvocationRecord( + tenant_id="t1", + session_id="sess-1", + task_id="frame-1", + run_id="run-x", + call_id="call-x", + tool_name=service.TOOL_NAME, + request_digest="rdx", + status="completed", + response_cache_json={"success": True, "data": {"submission_digest": digest}}, + ) + ) + lark_env["db"].commit() + result = _invoke( + lark_env, + { + "args": ["approval", "instances", "create", "--data", string_data], + "confirmed_form_digest": digest, + }, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + sent = json.loads(argv[argv.index("--data") + 1]) + assert isinstance(sent["form"], str) and sent["uuid"] + + +def test_service_submit_requires_confirmation_and_preview(lark_env) -> None: + data = '{"approval_code":"A","form":"[]"}' + submit_args = {"args": ["approval", "instances", "create", "--data", data]} + skill = _submit_skill() + + missing = _invoke(lark_env, submit_args, active_skill=skill, active_step_id="n_submit") + assert missing["error"]["subcode"] == "LARK_CLI_CONFIRMATION_REQUIRED" + + digest = policy.submission_digest(json.loads(data)) + mismatch = _invoke( + lark_env, + {**submit_args, "confirmed_form_digest": "sha256:wrong"}, + active_skill=skill, + active_step_id="n_submit", + ) + assert mismatch["error"]["subcode"] == "LARK_CLI_PREVIEW_DIGEST_MISMATCH" + + no_preview = _invoke( + lark_env, + {**submit_args, "confirmed_form_digest": digest}, + active_skill=skill, + active_step_id="n_submit", + ) + assert no_preview["error"]["subcode"] == "LARK_CLI_PREVIEW_NOT_FOUND" + + +def test_service_submit_blocked_outside_authorizing_sop_step(lark_env) -> None: + """流程闸:即便摘要与预览记录都合法,非授权节点/会话闲聊帧不得提交。""" + + data = '{"approval_code":"A","form":"[]"}' + digest = policy.submission_digest(json.loads(data)) + lark_env["db"].add( + HarnessInvocationRecord( + tenant_id="t1", + session_id="sess-1", + task_id="frame-1", + run_id="run-0", + call_id="call-0", + tool_name=service.TOOL_NAME, + request_digest="rd0", + status="completed", + response_cache_json={"success": True, "data": {"submission_digest": digest}}, + ) + ) + lark_env["db"].commit() + submit_args = { + "args": ["approval", "instances", "create", "--data", data], + "confirmed_form_digest": digest, + } + + conversation = _invoke(lark_env, submit_args) + assert conversation["error"]["subcode"] == "LARK_CLI_SUBMIT_REQUIRES_SOP" + # 拒绝消息必须重定向模型到正确动作(真实案例:旧文案导致模型误判任务失败)。 + assert "next_step_id" in conversation["error"]["message"] + assert "不是任务失败" in conversation["error"]["message"] + + other_step_skill = _submit_skill() + other_step_skill.content_json = { + "start_node_id": "n_chat", + "nodes": [{"node_id": "n_chat", "name": "闲聊", "capability_refs": {}}], + "edges": [], + } + unauthorized = _invoke( + lark_env, submit_args, active_skill=other_step_skill, active_step_id="n_chat" + ) + assert unauthorized["error"]["subcode"] == "LARK_CLI_SUBMIT_REQUIRES_SOP" + + # dry-run 与读命令不受流程闸限制。 + preview = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", data, "--dry-run"]}, + ) + assert preview["success"] is True + + +def test_service_submit_happy_path_injects_yes_and_uuid(lark_env) -> None: + data = '{"approval_code":"A","form":"[]"}' + digest = policy.submission_digest(json.loads(data)) + lark_env["db"].add( + HarnessInvocationRecord( + tenant_id="t1", + session_id="sess-1", + task_id="frame-1", + run_id="run-1", + call_id="call-1", + tool_name=service.TOOL_NAME, + request_digest="rd", + status="completed", + response_cache_json={ + "success": True, + "data": {"submission_digest": digest}, + }, + ) + ) + lark_env["db"].commit() + + result = _invoke( + lark_env, + { + "args": ["approval", "instances", "create", "--data", data], + "confirmed_form_digest": digest, + }, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert argv[-1] == "--yes" + assert "--as" in argv and argv[argv.index("--as") + 1] == "user" + sent_data = json.loads(argv[argv.index("--data") + 1]) + assert sent_data["uuid"] + assert sent_data["approval_code"] == "A" + # 同一帧同一内容 → uuid 稳定(服务端幂等的前提)。 + assert sent_data["uuid"] == service._submission_uuid("frame-1", digest) + + +def _add_preview_record( + env, + *, + digest: str, + canonical: dict | None, + task_id: str = "frame-0", + run_id: str = "run-p", + started_at: datetime | None = None, +) -> None: + data: dict = {"submission_digest": digest} + if canonical is not None: + data["canonical_data"] = canonical + env["db"].add( + HarnessInvocationRecord( + tenant_id="t1", + session_id="sess-1", + task_id=task_id, + run_id=run_id, + call_id=f"call-{run_id}", + tool_name=service.TOOL_NAME, + request_digest=f"rd-{run_id}", + status="completed", + response_cache_json={"success": True, "data": data}, + started_at=started_at or datetime.now(UTC), + ) + ) + env["db"].commit() + + +def test_service_submit_without_data_replays_latest_preview(lark_env) -> None: + """零参数提交:受信层重放会话内最近一次预览的规范化内容(跨帧核心修复)。 + + 真实案例:流程推进到提交节点的新帧后,模型丢失了预览帧的 + 表单上下文,重组时自造控件 id(widgetLeaveGroupType)被服务端打回。 + """ + + canonical = {"approval_code": "A", "form": [{"id": "w1", "value": "x"}]} + digest = policy.submission_digest(dict(canonical)) + _add_preview_record(lark_env, digest=digest, canonical=canonical) + + result = _invoke( + lark_env, + {"args": ["approval", "instances", "create"]}, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert argv[-1] == "--yes" + sent = json.loads(argv[argv.index("--data") + 1]) + assert sent["approval_code"] == "A" + # 线上格式:form 序列化为 JSON 字符串,内容与预览记录逐字段一致。 + assert json.loads(sent["form"]) == canonical["form"] + assert sent["uuid"] == service._submission_uuid("frame-1", digest) + + +def test_service_submit_without_data_requires_replayable_preview(lark_env) -> None: + no_record = _invoke( + lark_env, + {"args": ["approval", "instances", "create"]}, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert no_record["error"]["subcode"] == "LARK_CLI_PREVIEW_NOT_FOUND" + + # 最近一条预览缺少 canonical_data(旧记录)时同样拒绝,绝不回退到更早 + # 的内容——重放陈旧表单比报错更危险。 + canonical = {"approval_code": "A", "form": []} + old_digest = policy.submission_digest(dict(canonical)) + base = datetime.now(UTC) + _add_preview_record( + lark_env, + digest=old_digest, + canonical=canonical, + run_id="run-old", + started_at=base - timedelta(minutes=5), + ) + _add_preview_record( + lark_env, + digest="sha256:newer-without-canonical", + canonical=None, + run_id="run-new", + started_at=base, + ) + stale = _invoke( + lark_env, + {"args": ["approval", "instances", "create"]}, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert stale["error"]["subcode"] == "LARK_CLI_PREVIEW_NOT_FOUND" + + +def test_service_submit_without_data_still_requires_sop_step(lark_env) -> None: + """零参数重放不弱化结构闸:非授权节点照样拒绝。""" + + canonical = {"approval_code": "A", "form": []} + _add_preview_record( + lark_env, + digest=policy.submission_digest(dict(canonical)), + canonical=canonical, + ) + blocked = _invoke(lark_env, {"args": ["approval", "instances", "create"]}) + assert blocked["error"]["subcode"] == "LARK_CLI_SUBMIT_REQUIRES_SOP" + + +def test_service_submit_digest_path_accepts_cross_frame_preview(lark_env) -> None: + """显式 --data 路径的预览记录查找放宽到会话级(预览与提交常跨帧)。""" + + data = '{"approval_code":"A","form":"[]"}' + digest = policy.submission_digest(json.loads(data)) + _add_preview_record( + lark_env, digest=digest, canonical=None, task_id="frame-other" + ) + result = _invoke( + lark_env, + { + "args": ["approval", "instances", "create", "--data", data], + "confirmed_form_digest": digest, + }, + active_skill=_submit_skill(), + active_step_id="n_submit", + ) + assert result["success"] is True + + +def test_logical_write_signature_covers_no_data_submit() -> None: + """零参数提交也要有防重放签名(帧内重试命中重放缓存而非重复提交)。""" + + signature = policy.logical_write_signature( + {"args": ["approval", "instances", "create"]} + ) + assert signature is not None + # 与显式 --data 的提交签名不同:空内容摘要 vs 实际内容摘要。 + explicit = policy.logical_write_signature( + { + "args": [ + "approval", + "instances", + "create", + "--data", + '{"approval_code":"A","form":"[]"}', + ] + } + ) + assert explicit is not None and explicit != signature + + +_LEAVE_DEFINITION = [ + { + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + { + "id": "widgetLeaveGroupType", + "type": "radioV2", + "option": [ + {"value": "7678264870298471375", "text": "事假"}, + {"value": "7678264870520835028", "text": "病假"}, + ], + }, + {"id": "widgetLeaveGroupStartTime", "type": "date"}, + {"id": "widgetLeaveGroupReason", "type": "textarea"}, + ], + }, + {"id": "widgetRemark", "type": "textarea"}, +] + + +def test_validate_form_catches_real_failure_modes() -> None: + """真实案例合集:平铺复合子控件 / 自造 id / 选项用文字 / type 不符。""" + + flattened = [{"id": "widgetLeaveGroupType", "type": "radioV2", "value": "x"}] + errors = policy.validate_form_against_definition(flattened, _LEAVE_DEFINITION) + assert any("嵌套" in e and "widgetLeaveGroupV2" in e for e in errors) + + unknown = [{"id": "widgetMadeUp", "type": "input", "value": "x"}] + errors = policy.validate_form_against_definition(unknown, _LEAVE_DEFINITION) + assert any("不存在" in e for e in errors) + + text_not_key = [ + { + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + {"id": "widgetLeaveGroupType", "type": "radioV2", "value": "事假"} + ], + } + ] + errors = policy.validate_form_against_definition(text_not_key, _LEAVE_DEFINITION) + assert any("7678264870298471375" in e for e in errors) + + wrong_type = [{"id": "widgetRemark", "type": "input", "value": "x"}] + errors = policy.validate_form_against_definition(wrong_type, _LEAVE_DEFINITION) + assert any("type" in e for e in errors) + + not_array = [{"id": "widgetLeaveGroupV2", "type": "leaveGroupV2", "value": "x"}] + errors = policy.validate_form_against_definition(not_array, _LEAVE_DEFINITION) + assert any("子控件数组" in e for e in errors) + + +def test_validate_form_accepts_correct_nesting_and_missing_optional() -> None: + good = [ + { + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + { + "id": "widgetLeaveGroupType", + "type": "radioV2", + "value": "7678264870298471375", + }, + { + "id": "widgetLeaveGroupStartTime", + "type": "date", + "value": "2026-09-01T09:00:00+08:00", + }, + {"id": "widgetLeaveGroupReason", "type": "textarea", "value": "测试"}, + ], + } + # widgetRemark 缺省:宽松处交给服务端裁决,不报错。 + ] + assert policy.validate_form_against_definition(good, _LEAVE_DEFINITION) == [] + + +def test_service_dry_run_blocks_form_mismatch_before_preview(lark_env) -> None: + """CLI 的 --dry-run 不打服务端(真实案例:编造控件通过预览、用户确认后 + 才在真实提交时暴雷)——受信层用定义原文在预览前拦截。""" + + flattened = json.dumps( + { + "approval_code": "LEAVE_DEF", + "form": [{"id": "widgetLeaveGroupType", "type": "radioV2", "value": "x"}], + }, + ensure_ascii=False, + ) + blocked = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", flattened, "--dry-run"]}, + ) + assert blocked["success"] is False + assert blocked["error"]["subcode"] == "LARK_CLI_FORM_MISMATCH" + assert "嵌套" in blocked["error"]["message"] + + corrected = json.dumps( + { + "approval_code": "LEAVE_DEF", + "form": [ + { + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + { + "id": "widgetLeaveGroupType", + "type": "radioV2", + "value": "7678264870298471375", + } + ], + } + ], + }, + ensure_ascii=False, + ) + ok = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", corrected, "--dry-run"]}, + ) + assert ok["success"] is True + assert ok["data"]["submission_digest"].startswith("sha256:") + + +def test_service_dry_run_skips_validation_when_definition_unavailable(lark_env) -> None: + """定义获取失败/无定义数据时不阻塞预览(校验是增强不是门槛)。""" + + data = '{"approval_code":"A","form":[{"id":"anything","value":"x"}]}' + result = _invoke( + lark_env, + {"args": ["approval", "instances", "create", "--data", data, "--dry-run"]}, + ) + assert result["success"] is True + + +def test_service_translates_cli_error_envelope(lark_env) -> None: + result = _invoke( + lark_env, + {"args": ["approval", "approvals", "search", "--data", '{"keyword":"trigger_error"}']}, + ) + assert result["success"] is False + assert result["error"]["code"] == "LARK_CLI_PERMISSION_DENIED" + assert "auth login" in result["error"]["message"] + + +def test_service_typed_flag_read_runs_directly(lark_env) -> None: + """官方 typed 参数(--approval-code)零试错直达,且自动注入 --as user。""" + + result = _invoke( + lark_env, + {"args": ["approval", "approvals", "get", "--approval-code", "CODE-1"]}, + ) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert "--approval-code" in argv and "CODE-1" in argv + assert argv[argv.index("--as") + 1] == "user" + + +def test_service_dynamic_read_allowed_by_cli_risk(lark_env) -> None: + """未登记命令:CLI 自标 Risk: read 即放行,并按 help 注入 --as user。""" + + result = _invoke(lark_env, {"args": ["im", "messages", "list", "--page-size", "5"]}) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert argv[:3] == ["im", "messages", "list"] + assert argv[argv.index("--as") + 1] == "user" + + +def test_service_dynamic_write_denied_by_cli_risk(lark_env) -> None: + """未登记的写命令(审批处置等)即便探测也不放行,需显式登记。""" + + result = _invoke( + lark_env, {"args": ["approval", "tasks", "approve", "--data", "{}"]} + ) + assert result["success"] is False + assert result["error"]["subcode"] == "LARK_CLI_COMMAND_BLOCKED" + assert "策略表" in result["error"]["message"] + # 真实案例:模型曾建议用户"在本地终端运行 lark-cli auth logout"——登录态 + # 在系统托管的隔离 HOME 里,终端操作无效。拒绝文案必须堵住这类误导。 + assert "不要建议用户在本地终端" in result["error"]["message"] + + +def test_service_auth_logout_allowed(lark_env) -> None: + """换账号闭环:logout 在免确认写名单内,直接执行并自动补 --json。""" + + result = _invoke(lark_env, {"args": ["auth", "logout"]}) + assert result["success"] is True + argv = result["data"]["envelope"]["argv"] + assert argv[:2] == ["auth", "logout"] and "--json" in argv + + +def test_policy_safe_write_allowlist_is_local_state_only() -> None: + """守护测试:免确认写名单只允许"纯本地态操作",防止以后误把有外部 + 副作用的命令(如 im messages send)加成 write 规则绕过确认。""" + + local_state_only = {("auth", "login"), ("auth", "logout"), ("config", "init")} + for rule in policy._RULES: + if rule.action == "write": + assert rule.prefix in local_state_only, rule.prefix + elif rule.action == "gated_write": + assert rule.prefix == ("approval", "instances", "create"), rule.prefix + + +def test_policy_config_init_rules() -> None: + resolved = policy.resolve( + ["config", "init", "--app-id", "cli_x", "--app-secret", "s3cret"] + ) + assert resolved.rule.action == "write" and not resolved.is_help + assert policy.resolve(["config", "init", "--new"]).rule.prefix == ("config", "init") + # config 域其余子命令与危险 flag 保持封禁;--help 放行。 + with pytest.raises(policy.LarkCliPolicyError): + policy.resolve(["config", "bind"]) + with pytest.raises(policy.LarkCliPolicyError): + policy.resolve(["config", "init", "--new", "--force-init"]) + assert policy.resolve(["config", "init", "--help"]).is_help + # 应用配置是可重试的写操作,不纳入防重放签名。 + assert ( + policy.logical_write_signature( + {"args": ["config", "init", "--app-id", "a", "--app-secret", "b"]} + ) + is None + ) + + +def test_service_conversational_config_init_then_commands_work(lark_env_unbound) -> None: + """对话内提供 app_id/secret 完成配置后,后续命令无需绑定/settings 即可运行。""" + + result = _invoke( + lark_env_unbound, + {"args": ["config", "init", "--app-id", "cli_chat_app", "--app-secret", "chat-secret"]}, + ) + assert result["success"] is True + assert result["data"]["configured_app_ids"] == ["cli_chat_app"] + # secret 不出现在结果里(进入对话是用户自己的选择,但执行面必须脱敏)。 + assert "chat-secret" not in json.dumps(result, ensure_ascii=False) + followup = _invoke(lark_env_unbound, {"args": ["auth", "status"]}) + assert followup["success"] is True + + +def test_service_config_init_requires_credentials_or_new(lark_env_unbound) -> None: + result = _invoke(lark_env_unbound, {"args": ["config", "init", "--app-id", "cli_x"]}) + assert result["error"]["subcode"] == "LARK_CLI_CONFIG_ARGS_REQUIRED" + + +def test_service_config_init_new_pending_then_configured(lark_env_unbound) -> None: + """--new 后台流程:先拿到 verification_url,用户完成后查询到 configured。""" + + first = _invoke(lark_env_unbound, {"args": ["config", "init", "--new"]}) + assert first["success"] is True + assert first["data"]["status"] == "pending_user" + assert "open.feishu.cn" in str(first["data"]["verification_url"]) + # 模拟用户在浏览器完成创建:CLI 会把新应用写进 HOME 配置。 + home = runner.user_home_dir("t1", "u1") + config_dir = home / ".lark-cli" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text( + json.dumps({"apps": [{"appId": "cli_new_app", "brand": "feishu"}]}), + encoding="utf-8", + ) + second = _invoke(lark_env_unbound, {"args": ["config", "init", "--new"]}) + assert second["success"] is True + assert second["data"]["status"] == "configured" + assert second["data"]["app_ids"] == ["cli_new_app"] + followup = _invoke(lark_env_unbound, {"args": ["auth", "status"]}) + assert followup["success"] is True + + +def test_audit_arguments_redact_secret_flag_values() -> None: + """对话内提供的 --app-secret 值不得明文落进 harness_invocations 审计记录。""" + + from app.core.harness_capability_invoker import _audit_arguments + + audited = _audit_arguments( + {"args": ["config", "init", "--app-id", "cli_x", "--app-secret", "s3cret"]} + ) + assert audited["args"] == [ + "config", "init", "--app-id", "cli_x", "--app-secret", "" + ] + # 普通 argv 不受影响。 + assert _audit_arguments({"args": ["auth", "status"]})["args"] == ["auth", "status"] + + +def test_background_failure_reports_output_tail(tmp_path: Path) -> None: + from app.lark_cli import background + + binary = tmp_path / "failing-cli" + binary.write_text( + "#!/usr/bin/env python3\nimport sys\nprint('boom: cannot create app')\nsys.exit(2)\n", + encoding="utf-8", + ) + binary.chmod(0o755) + home = tmp_path / "home" + home.mkdir() + status = background.config_init_new_status(binary, home) + assert status["status"] == "failed" + assert "boom" in str(status["detail"]) + assert str(home) not in background._REGISTRY + + +def test_service_secret_never_reaches_argv_or_output(lark_env) -> None: + result = _invoke(lark_env, {"args": ["auth", "status"]}) + blob = json.dumps(result, ensure_ascii=False) + assert "app-secret-value" not in blob + + +# --------------------------------------------------------------------------- +# manifest descriptor +# --------------------------------------------------------------------------- + + +def test_lark_cli_reserved_name() -> None: + assert "lark_cli" in RESERVED_HARNESS_CAPABILITY_NAMES + + +def test_sample_sop_card_is_valid_and_authorizes_submission() -> None: + """docs/lark-cli-approval-sop.json 必须始终能通过 SkillCard 校验。""" + + from app.core.task_request_compiler import current_step_capability_refs + from app.skills.skill_schema import SkillCard + + card_path = ( + Path(__file__).resolve().parents[2] / "docs" / "lark-cli-approval-sop.json" + ) + card = SkillCard.model_validate(json.loads(card_path.read_text(encoding="utf-8"))) + # 鉴权必须先于任何飞书调用:收集节点本身要检索审批定义(需登录态), + # 起点若排在它之后,未登录用户必然先撞一次 TOKEN_MISSING(真实案例)。 + assert card.start_node_id == "n_auth_check" + edges = { + (edge.source_node_id, edge.next_node_id) for edge in card.edges + } + assert ("n_auth_check", "n_collect") in edges + assert ("n_auth_complete", "n_collect") in edges + assert ("n_collect", "n_preview") in edges + + skill = Skill( + tenant_id="t1", + skill_id=card.skill_id, + name=card.name, + content_json=card.model_dump(mode="json"), + ) + refs = current_step_capability_refs(skill, "n_submit") + assert "lark_cli" in refs["tool_ids"] + assert service._step_authorizes_submission(skill, "n_submit") is True + # 预览节点不得持有提交授权:用户确认前模型在该节点无法真实提交。 + assert service._step_authorizes_submission(skill, "n_preview") is False + # 确认节点(n_preview)声明了必须等待的用户信息 → awaiting_user 流程闸成立。 + preview_node = next( + node for node in card.nodes if node.node_id == "n_preview" + ) + assert preview_node.expected_user_info + + +def test_gallery_seed_fixture_matches_docs_card() -> None: + """扩展种子里的审批 SOP 必须与 docs 样例卡逐字节一致,防止双份漂移。""" + + root = Path(__file__).resolve().parents[2] + card = json.loads( + (root / "docs" / "lark-cli-approval-sop.json").read_text(encoding="utf-8") + ) + fixture = json.loads( + ( + root + / "backend" + / "app" + / "db" + / "seed_fixtures" + / "staffdeck_expanded_gallery_seed.json" + ).read_text(encoding="utf-8") + ) + skill_rows = [ + row + for row in fixture["skills"] + if row.get("skill_id") == card["skill_id"] + ] + assert len(skill_rows) == 1 + assert skill_rows[0]["content_json"] == card + assert skill_rows[0]["status"] == "published" + version_rows = [ + row + for row in fixture["skill_versions"] + if row.get("skill_id") == card["skill_id"] + ] + assert len(version_rows) == 1 and version_rows[0]["content_json"] == card + bindings = [ + row + for row in fixture["agent_resource_bindings"] + if row.get("resource_id") == skill_rows[0]["id"] + ] + assert len(bindings) == 1 + assert bindings[0]["resource_type"] == "skill" + assert bindings[0]["status"] == "active" + + +def test_manifest_descriptor_gating(monkeypatch, db: Session) -> None: + import app.core.capability_manifest as manifest_module + + monkeypatch.setattr( + "app.config.get_settings", lambda: Settings(lark_cli_enabled=False) + ) + assert manifest_module._lark_cli_descriptor(db, "t1", None) is None + + monkeypatch.setattr( + "app.config.get_settings", lambda: Settings(lark_cli_enabled=True) + ) + # 凭据可在对话内建立(config init),因此开启即视为可用,无需预检凭据。 + descriptor = _lark_cli_descriptor(db, "t1", None) + assert descriptor is not None and descriptor.available is True + assert descriptor.name == "lark_cli" and descriptor.kind == "internal" + assert "config init" in descriptor.description diff --git a/backend/tests/test_staffdeck_seed.py b/backend/tests/test_staffdeck_seed.py index ac22bbd56..ce252513d 100644 --- a/backend/tests/test_staffdeck_seed.py +++ b/backend/tests/test_staffdeck_seed.py @@ -94,12 +94,17 @@ def test_staffdeck_seed_requires_every_bundled_fixture(tmp_path) -> None: def test_expanded_staffdeck_skills_match_runtime_schema() -> None: data = json.loads(staffdeck_seed.EXPANDED_FIXTURE_PATH.read_text(encoding="utf-8")) - for key in ( - "skills", - "skill_versions", - "agent_skill_branches", - "agent_skill_branch_versions", - ): + # 每个预置员工一张卡;此外允许绑定到主种子员工的策划卡(如飞书审批提交)。 + curated_extra_skill_ids = {"lark_approval_submit"} + for key in ("skills", "skill_versions"): + rows = data[key] + assert len(rows) == ( + len(EXPECTED_EXPANDED_EMPLOYEE_PROFILES) + len(curated_extra_skill_ids) + ) + assert curated_extra_skill_ids <= {row["skill_id"] for row in rows} + for row in rows: + SkillCard.model_validate(row["content_json"]) + for key in ("agent_skill_branches", "agent_skill_branch_versions"): rows = data[key] assert len(rows) == len(EXPECTED_EXPANDED_EMPLOYEE_PROFILES) for row in rows: diff --git a/docs/lark-cli-approval-sop.json b/docs/lark-cli-approval-sop.json new file mode 100644 index 000000000..8122b81ab --- /dev/null +++ b/docs/lark-cli-approval-sop.json @@ -0,0 +1,179 @@ +{ + "skill_id": "lark_approval_submit", + "name": "飞书审批提交", + "version": "1.0.0", + "business_domain": "办公协作", + "description": "通过飞书官方 lark-cli 以用户本人身份提交审批申请:应用凭据缺失时可在对话内一次性配置(提供现有应用凭据或现场创建新应用),随后完成一次性设备码授权登录,再检索审批定义、组装表单并 dry-run 预览,把内容逐字段展示给用户,获得明确确认后才真实提交。", + "trigger_intents": [ + "提交审批", + "发起审批", + "提审批单", + "帮我走审批流程" + ], + "user_utterance_examples": [ + "帮我提一个报销审批", + "发起一个请假审批,下周一到周三", + "走一下采购审批流程" + ], + "goal": [ + "以用户本人的飞书身份成功创建审批实例,并把审批单链接反馈给用户", + "提交内容必须与用户确认过的预览逐字节一致" + ], + "response_rules": [ + "展示预览时必须逐字段列出表单内容,不得省略", + "用户未明确回复确认前,绝不调用真实提交;但用户已确认且内容未变时不得重复索要确认,直接推进提交", + "工具调用失败必须如实告知用户失败与原因,严禁把失败或\"即将提交\"表述为已提交;提交成功的唯一标志是返回 instance_code;标记 retryable=true 的报错是可恢复错误,不得据此宣告任务失败或提前结束步骤——应按错误信息中的指引修正重试,或如实说明后留在当前步骤等待用户回复", + "所有飞书相关操作只能通过 lark_cli 能力完成,禁止使用 exec_command 等其他方式操作飞书或计算日期(当前时间在任务书 current_time 字段里)", + "任何 lark-cli 错误要翻译成用户能懂的话,并给出下一步建议", + "用户在对话中提供的 App Secret 只用于调用 config init,绝不在回复中复述或展示", + "收集信息时不过度追问:有合理默认或可推断的字段直接采用默认值,预览时统一让用户核对修改" + ], + "start_node_id": "n_auth_check", + "terminal_node_ids": [ + "n_done" + ], + "nodes": [ + { + "node_id": "n_auth_check", + "type": "action", + "name": "检查登录状态", + "instruction": "调用 lark_cli 执行 auth status --json 确认登录态(这是后续所有飞书操作的前提,包括检索审批定义)。若返回错误提示应用未配置(LARK_CLI_APP_NOT_CONFIGURED),进入应用配置步骤。否则查看 identities.user.available 是否为 true(用户身份是否已登录):已登录则进入信息收集步骤;未登录则进入发起授权步骤。" + }, + { + "node_id": "n_app_setup", + "type": "collect_info", + "name": "配置飞书应用", + "instruction": "当前还没有可用的飞书应用凭据,需在对话中完成一次性配置。向用户说明并给出两个选项:A) 已有飞书应用——请用户提供应用的 App ID 和 App Secret(飞书开放平台 https://open.feishu.cn 应用详情页的「凭证与基础信息」可查),拿到后调用 lark_cli 执行 config init --app-id --app-secret ;不要在回复中复述用户提供的 App Secret。B) 没有应用——调用 lark_cli 执行 config init --new,把返回的 verification_url 发给用户,请其在浏览器中登录飞书开放平台完成应用创建并回复完成;用户回复后再次调用 config init --new 查询进度,直到返回 configured。配置成功后回到登录状态检查步骤。注意:新创建的应用还需要在开放平台开通审批相关权限(approval:approval:read、approval:instance:write 等)并发布版本,后续登录步骤会检查并给出指引。", + "expected_user_info": [ + "应用凭据(App ID 与 App Secret)或应用创建完成的确认" + ] + }, + { + "node_id": "n_auth_start", + "type": "collect_info", + "name": "发起授权登录", + "instruction": "进入本步骤后的第一个动作必须是调用 lark_cli 执行 auth login --scope \"approval:approval:read approval:instance:read approval:instance:write approval:task:read approval:task:write\" --no-wait --json,从结果取 verification_url 与 device_code:把 verification_url 原样发给用户,请用户打开链接完成飞书授权并在完成后回复确认;device_code 原样保留用于下一步收尾。不要在登录前调用 auth scopes 查询应用权限——该命令需要应用管理类权限(admin:app.info:readonly 等),普通审批应用并未申请,调用必然失败并白白多耗一轮。若登录或后续审批调用报 app_scope_not_applied,说明该应用后台尚未开通审批权限,请如实告知用户需由管理员在飞书开放平台按错误提示中的链接申请权限并发布版本。不要尝试生成二维码(auth qrcode 等命令未开放,控制台也无法展示本地图片),把链接原样发给用户即可。本步骤在把 verification_url 发给用户之前不算完成;期间任何命令报错都不构成任务失败——如实向用户说明并留在本步骤等待用户回复,严禁直接宣告失败。", + "expected_user_info": [ + "用户已完成飞书授权的确认" + ] + }, + { + "node_id": "n_auth_complete", + "type": "action", + "name": "完成授权登录", + "instruction": "用户确认已授权后,调用 lark_cli 执行 auth login --device-code <上一步的 device_code> --json 完成登录,再用 auth status --json 复核身份与 scope。若提示 authorization_pending,请用户完成授权后重试一次。登录成功后进入信息收集步骤。" + }, + { + "node_id": "n_collect", + "type": "collect_info", + "name": "收集审批需求", + "instruction": "了解用户要提交哪类审批(关键词,如:报销/请假/采购)以及表单需要的信息。用 lark_cli 的 approval approvals search(--data 里带 keyword)检索可发起的审批定义,确认后用 approvals get --approval-code 查看该定义的表单控件结构。据此向用户收集缺失的字段值(一次性问全,不要分多轮);选择类控件要把可选项列给用户(记录每个选项文字对应的 key)。不过度追问:凡是能从用户已给信息合理推断或有惯例默认值的字段,直接采用默认并留到预览步骤让用户核对,不要再问——例如日期给到天即视为全天,按 09:00 至 18:00 组装起止时间。用户在收集阶段回复「提交/确认/用默认」等,视为同意用合理默认补齐全部缺失字段,立即停止提问并进入下一步骤。涉及\"明天/下周一\"等相对日期时,用任务书 current_time 字段换算,不要调用命令获取日期。", + "expected_user_info": [ + "审批类型或关键词", + "表单所需字段的取值" + ] + }, + { + "node_id": "n_preview", + "type": "collect_info", + "name": "预览并等待用户确认", + "instruction": "根据已收集的信息组装 --data:approval_code 加 form。form 组装规范(务必遵守):① 控件 id、type 与嵌套结构必须逐一取自 approvals get 返回的 form 定义,严禁自造控件 id;复合控件(如 leaveGroupV2)的子控件必须嵌套在该复合控件的 value 数组内整体组装,严禁平铺到 form 顶层;② radioV2/select 等选择类控件的 value 必须用定义返回的选项 key,不是选项文字;③ date 控件的值推荐写 RFC3339(如 2026-08-27T09:00:00+08:00);定义详情里显示的 YYYY-MM-DD hh:mm 只是展示格式,系统也会自动把这类常见日期写法规范化为 RFC3339;④ form 传 JSON 数组或字符串均可,系统会自动规范化为官方线上格式。然后调用 lark_cli 执行 approval instances create --data --dry-run,获得预览与 submission_digest。向用户逐字段展示:审批定义名称、每个字段的名称与取值(选择类字段同时展示选项文字)。明确询问用户是否确认提交并等待回复。用户回复确认(如「提交」「确认」「OK」「没问题」)后:严禁再次询问确认,也严禁在本步骤调用真实提交(系统会拒绝)——正确做法是在 finish 时把 slot_updates 写入 {\"用户对提交内容的明确确认\": \"已确认\"} 并给出 next_step_id 进入提交步骤,由提交步骤执行真实提交。用户要求修改时回到收集步骤重新组装并重新预览。", + "expected_user_info": [ + "用户对提交内容的明确确认" + ] + }, + { + "node_id": "n_submit", + "type": "action", + "name": "提交审批", + "instruction": "仅在用户明确确认后执行。进入本步骤后的第一个动作必须是调用 lark_cli 完成真实提交:args 只需 [\"approval\",\"instances\",\"create\"],不带 --data 也不带 confirmed_form_digest——系统会自动提交用户在预览步骤确认过的表单内容并注入 --yes 与幂等 uuid,严禁重新获取审批定义或重新组装表单。此前步骤的 dry-run 预览不等于提交,严禁未调用提交就结束本步骤,也严禁因上一步骤的拒绝报错而直接宣告失败。提交成功的唯一标志是返回结果中包含 instance_code——此时向用户反馈审批单链接。若提交失败:必须如实告知用户失败原因,严禁表述为已提交或即将提交;若系统提示没有可重放的预览记录或表单被服务端拒绝,回到预览步骤重新组装 --data、重新 dry-run 并再次征得用户确认后才能重试提交。", + "capability_refs": { + "tool_ids": [ + "lark_cli" + ], + "required_tool_ids": [ + "lark_cli" + ] + } + }, + { + "node_id": "n_done", + "type": "action", + "name": "完成", + "instruction": "向用户反馈审批已提交成功、审批单链接与后续查看方式(也可用 approval tasks query / instances get 查询进度)。" + }, + { + "node_id": "n_handoff", + "type": "handoff", + "name": "转人工处理", + "instruction": "授权多次失败、审批定义不存在、或提交持续报错且用户希望人工介入时,转交人工处理并附上已收集的上下文。" + } + ], + "edges": [ + { + "source_node_id": "n_auth_check", + "next_node_id": "n_collect", + "condition": "auth status 显示用户身份已登录(identities.user.available 为 true)" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_auth_start", + "condition": "用户身份尚未登录" + }, + { + "source_node_id": "n_auth_check", + "next_node_id": "n_app_setup", + "condition": "报错应用未配置(LARK_CLI_APP_NOT_CONFIGURED)" + }, + { + "source_node_id": "n_app_setup", + "next_node_id": "n_auth_check", + "condition": "应用凭据配置成功(configured)" + }, + { + "source_node_id": "n_auth_start", + "next_node_id": "n_auth_complete", + "condition": "用户回复已完成授权" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_collect", + "condition": "登录成功且 scope 满足" + }, + { + "source_node_id": "n_auth_complete", + "next_node_id": "n_handoff", + "condition": "多次尝试后登录仍失败且用户希望人工协助" + }, + { + "source_node_id": "n_collect", + "next_node_id": "n_preview", + "condition": "审批类型与表单信息已收集齐全" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_submit", + "condition": "用户明确确认提交内容无误" + }, + { + "source_node_id": "n_preview", + "next_node_id": "n_collect", + "condition": "用户要求修改表单内容" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_done", + "condition": "审批实例创建成功" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_handoff", + "condition": "提交持续失败且用户希望人工协助" + }, + { + "source_node_id": "n_submit", + "next_node_id": "n_preview", + "condition": "提交被服务端拒绝(如表单格式错误),需修正后重新预览并让用户确认" + } + ] +}