diff --git a/backend/app/services/agent_runtime/chat_intake.py b/backend/app/services/agent_runtime/chat_intake.py index bf82d0ce7..38a248c3f 100644 --- a/backend/app/services/agent_runtime/chat_intake.py +++ b/backend/app/services/agent_runtime/chat_intake.py @@ -574,6 +574,7 @@ async def enqueue_chat_runtime( display_content=display_content, file_name=file_name, ) + confirmation_text = (display_content or content).strip() resumed_run: AgentRun | None = None if resume_run_id is not None: resumed_run = await _require_resume_run( @@ -642,6 +643,7 @@ async def enqueue_chat_runtime( "payload": { "message_id": str(resolved_message_id), "content": runtime_content, + "confirmation_text": confirmation_text, }, }, actor_user_id=user.id, diff --git a/backend/app/services/agent_runtime/feishu_approval_authorization.py b/backend/app/services/agent_runtime/feishu_approval_authorization.py new file mode 100644 index 000000000..26ac8cf14 --- /dev/null +++ b/backend/app/services/agent_runtime/feishu_approval_authorization.py @@ -0,0 +1,154 @@ +"""Ephemeral, receipt-bound authorization for Feishu approval creation.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import hashlib +import hmac +import json +import secrets + + +_AUTHORIZATION_KEY = secrets.token_bytes(32) + + +@dataclass(frozen=True, slots=True) +class FeishuApprovalCreateAuthorization: + """One Runtime confirmation bound to one live Tool Ledger receipt.""" + + run_id: str + tool_call_id: str + execution_id: str + lease_owner: str + tenant_id: str + agent_id: str + actor_user_id: str + arguments_hash: str + signature: str + + +def feishu_approval_create_arguments_hash( + arguments: Mapping[str, object], +) -> str: + encoded = json.dumps( + dict(arguments), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _signature( + *, + run_id: str, + tool_call_id: str, + execution_id: str, + lease_owner: str, + tenant_id: str, + agent_id: str, + actor_user_id: str, + arguments_hash: str, +) -> str: + payload = "\n".join( + ( + run_id, + tool_call_id, + execution_id, + lease_owner, + tenant_id, + agent_id, + actor_user_id, + arguments_hash, + ) + ).encode("utf-8") + return hmac.new(_AUTHORIZATION_KEY, payload, hashlib.sha256).hexdigest() + + +def issue_feishu_approval_create_authorization( + *, + run_id: str, + tool_call_id: str, + execution_id: str, + lease_owner: str, + tenant_id: str, + agent_id: str, + actor_user_id: str, + arguments: Mapping[str, object], +) -> FeishuApprovalCreateAuthorization: + """Issue a process-local proof after exact consent and reservation.""" + arguments_hash = feishu_approval_create_arguments_hash(arguments) + signature = _signature( + run_id=run_id, + tool_call_id=tool_call_id, + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=agent_id, + actor_user_id=actor_user_id, + arguments_hash=arguments_hash, + ) + return FeishuApprovalCreateAuthorization( + run_id=run_id, + tool_call_id=tool_call_id, + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=agent_id, + actor_user_id=actor_user_id, + arguments_hash=arguments_hash, + signature=signature, + ) + + +def verify_feishu_approval_create_authorization( + authorization: FeishuApprovalCreateAuthorization | None, + *, + run_id: str, + tool_call_id: str, + execution_id: str, + lease_owner: str, + tenant_id: str, + agent_id: str, + actor_user_id: str, + arguments: Mapping[str, object], +) -> bool: + """Verify a proof against independently supplied current Runtime facts.""" + if authorization is None: + return False + arguments_hash = feishu_approval_create_arguments_hash(arguments) + expected_fields = ( + run_id, + tool_call_id, + execution_id, + lease_owner, + tenant_id, + agent_id, + actor_user_id, + arguments_hash, + ) + actual_fields = ( + authorization.run_id, + authorization.tool_call_id, + authorization.execution_id, + authorization.lease_owner, + authorization.tenant_id, + authorization.agent_id, + authorization.actor_user_id, + authorization.arguments_hash, + ) + if actual_fields != expected_fields: + return False + expected_signature = _signature( + run_id=run_id, + tool_call_id=tool_call_id, + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=agent_id, + actor_user_id=actor_user_id, + arguments_hash=arguments_hash, + ) + return hmac.compare_digest(authorization.signature, expected_signature) diff --git a/backend/app/services/agent_runtime/node_executor.py b/backend/app/services/agent_runtime/node_executor.py index 1519b65c5..46ddc3517 100644 --- a/backend/app/services/agent_runtime/node_executor.py +++ b/backend/app/services/agent_runtime/node_executor.py @@ -400,6 +400,20 @@ def _resume_message_content(resume_value: Mapping[str, JsonValue]) -> str | list ) +def _resume_confirmation_text( + resume_value: Mapping[str, JsonValue], +) -> str | None: + if resume_value.get("resume_type") != "user_input": + return None + payload = resume_value.get("payload") + if not isinstance(payload, Mapping): + return None + confirmation_text = payload.get("confirmation_text") + if not isinstance(confirmation_text, str) or not confirmation_text.strip(): + return None + return confirmation_text.strip()[:500] + + def _runtime_message_id(context: RuntimeContext, position: str) -> str: return str(uuid.uuid5(uuid.UUID(context.run_id), position)) @@ -844,8 +858,25 @@ async def _tool( context, (current_call,), ) - pending_calls = (*result.pending_tool_calls, *tail_calls) + resumed_waiting_request = state["lifecycle"].get( + "resumed_waiting_request" + ) + discard_tail_calls = ( + isinstance(resumed_waiting_request, Mapping) + and resumed_waiting_request.get( + "discard_remaining_tool_calls_on_resume" + ) + is True + and resumed_waiting_request.get("tool_call_id") + == current_call.get("id") + ) + pending_calls = ( + tuple(result.pending_tool_calls) + if discard_tail_calls + else (*result.pending_tool_calls, *tail_calls) + ) lifecycle = dict(state["lifecycle"]) + lifecycle.pop("resumed_waiting_request", None) lifecycle.update( { "pending_tool_calls": [dict(call) for call in pending_calls], @@ -1086,6 +1117,9 @@ async def _wait( ) lifecycle = dict(state["lifecycle"]) waiting_status = state["lifecycle"]["status"] + waiting_request = _validate_waiting_request( + cast(JsonObject | None, state["lifecycle"].get("waiting_request")) + ) lifecycle.update( { "status": "running", @@ -1105,8 +1139,14 @@ async def _wait( "runtime_input": "resume", "runtime_run_id": context.run_id, }) + confirmation_text = _resume_confirmation_text( + cast(Mapping[str, JsonValue], resume_value) + ) + if confirmation_text is not None: + resume_message["runtime_confirmation_text"] = confirmation_text pending_calls = _tool_calls(cast(RuntimeLifecycle, lifecycle)) if waiting_status == "waiting_user" and pending_calls: + lifecycle["resumed_waiting_request"] = waiting_request deferred = lifecycle.get("deferred_resume_messages", []) if not isinstance(deferred, list) or any( not isinstance(message, Mapping) for message in deferred diff --git a/backend/app/services/agent_runtime/state.py b/backend/app/services/agent_runtime/state.py index bc53111a9..975039d17 100644 --- a/backend/app/services/agent_runtime/state.py +++ b/backend/app/services/agent_runtime/state.py @@ -110,6 +110,7 @@ class RuntimeLifecycle(TypedDict): pending_group_at: NotRequired[JsonObject | None] deferred_resume_messages: NotRequired[list[JsonObject]] waiting_request: NotRequired[JsonObject | None] + resumed_waiting_request: NotRequired[JsonObject] verification_result: NotRequired[JsonObject | None] final_answer: NotRequired[str | None] finish_delivery_intent: NotRequired[JsonObject | None] diff --git a/backend/app/services/agent_runtime/tool_execution.py b/backend/app/services/agent_runtime/tool_execution.py index 46310aa19..079128e2d 100644 --- a/backend/app/services/agent_runtime/tool_execution.py +++ b/backend/app/services/agent_runtime/tool_execution.py @@ -110,6 +110,10 @@ "db_status", "projection_status", "provider", + "provider_http_status", + "provider_code", + "provider_msg", + "provider_response_body", "operation", "project_id", "project_name", diff --git a/backend/app/services/agent_runtime/tool_result_store.py b/backend/app/services/agent_runtime/tool_result_store.py index ac7b5d777..a25fc1838 100644 --- a/backend/app/services/agent_runtime/tool_result_store.py +++ b/backend/app/services/agent_runtime/tool_result_store.py @@ -45,6 +45,10 @@ "archive_status", "archive_error_code", "provider", + "provider_http_status", + "provider_code", + "provider_msg", + "provider_response_body", "operation", "project_id", "project_name", diff --git a/backend/app/services/agent_runtime/tool_step_service.py b/backend/app/services/agent_runtime/tool_step_service.py index 75e64ec10..050769573 100644 --- a/backend/app/services/agent_runtime/tool_step_service.py +++ b/backend/app/services/agent_runtime/tool_step_service.py @@ -75,11 +75,17 @@ ToolResultReconciler, ToolResultStore, ) +from app.services.agent_runtime.feishu_approval_authorization import ( + FeishuApprovalCreateAuthorization, + feishu_approval_create_arguments_hash, + issue_feishu_approval_create_authorization, +) from app.services.autonomy_service import autonomy_service from app.services.agent_tools import ( agentbay_run_scope_id, execute_builtin_tool_outcome, get_runtime_agent_tools_for_llm, + validate_feishu_approval_create_arguments, ) from app.services.builtin_tool_definitions import ( builtin_cross_space_action, @@ -94,6 +100,26 @@ "plaza_create_post": 1, "plaza_add_comment": 2, } +_FEISHU_APPROVAL_CREATE_TOOL = "feishu_approval_create" +_FEISHU_APPROVAL_CONFIRMATION_REASON = ( + "feishu_approval_create_confirmation" +) +_FEISHU_APPROVAL_CONFIRMATION_REJECT = frozenset( + { + "不确认", + "不同意", + "不要发起", + "取消", + "取消发起", + "拒绝", + "停止", + "cancel", + "no", + "reject", + "rejected", + "stop", + } +) async def _insert_runtime_activity( @@ -134,6 +160,13 @@ async def __call__( user_id: uuid.UUID, session_id: str = "", on_output: object | None = None, + *, + runtime_authorization: FeishuApprovalCreateAuthorization | None = None, + runtime_run_id: str | None = None, + runtime_tool_call_id: str | None = None, + runtime_execution_id: str | None = None, + runtime_lease_owner: str | None = None, + runtime_tenant_id: str | None = None, ) -> ToolExecutionOutcome | str: ... @@ -566,6 +599,193 @@ def _delete_autonomy_details( } +def _feishu_approval_confirmation_correlation( + *, + run_id: uuid.UUID, + call_id: str, + arguments: Mapping[str, object], +) -> tuple[str, str]: + digest = feishu_approval_create_arguments_hash(arguments) + correlation_id = str( + uuid.uuid5( + run_id, + f"feishu-approval-confirm:{call_id}:{digest}", + ) + ) + return correlation_id, digest + + +def _feishu_approval_confirmation_summary( + validated: Mapping[str, object], +) -> str: + approval_code = cast(str, validated["approval_code"]) + target_member_id = cast(str, validated["target_member_id"]) + parsed_form = cast(list, validated["parsed_form"]) + approval_fingerprint = hashlib.sha256( + approval_code.encode("utf-8") + ).hexdigest()[:8].upper() + return ( + f"审批定义标识 {approval_fingerprint};" + f"发起成员 ID {target_member_id[:8]}…;" + f"表单字段 {len(parsed_form)} 项" + ) + + +def _feishu_approval_confirmation_reply( + state: RuntimeGraphState, +) -> str | None: + messages = state["lifecycle"].get("deferred_resume_messages") + if not isinstance(messages, list) or not messages: + return None + latest = messages[-1] + if ( + not isinstance(latest, Mapping) + or latest.get("role") != "user" + or latest.get("runtime_input") != "resume" + ): + return None + content = latest.get("runtime_confirmation_text") + return content if isinstance(content, str) and content.strip() else None + + +def _feishu_approval_confirmation_gate( + *, + state: RuntimeGraphState, + context: RuntimeContext, + call_id: str, + tool_name: str, + arguments: Mapping[str, object], +) -> tuple[ + ToolExecutionOutcome | None, + JsonObject | None, + bool, +]: + if tool_name != _FEISHU_APPROVAL_CREATE_TOOL: + return None, None, False + if ( + context.source_type != "chat" + or not context.session_id + or not context.actor_user_id + ): + return ToolExecutionOutcome( + status="failed", + result_summary=( + "Feishu approval creation requires an authenticated human " + "confirmation in the active Chat Run; no approval instance " + "was created." + ), + result_ref=None, + error_code="tool_confirmation_unavailable", + retryable=False, + metadata={"confirmation_status": "unavailable"}, + ), None, False + validated, validation_error = validate_feishu_approval_create_arguments( + dict(arguments) + ) + if validation_error is not None or validated is None: + return validation_error or ToolExecutionOutcome( + status="failed", + result_summary=( + "Feishu approval creation arguments are invalid; no approval " + "instance was created." + ), + result_ref=None, + error_code="invalid_tool_arguments", + retryable=False, + ), None, False + try: + correlation_id, arguments_hash = ( + _feishu_approval_confirmation_correlation( + run_id=uuid.UUID(context.run_id), + call_id=call_id, + arguments=arguments, + ) + ) + except (TypeError, ValueError) as exc: + raise ToolExecutionError( + "invalid_tool_call", + "Feishu approval confirmation requires serializable arguments.", + ) from exc + + resumed_request = state["lifecycle"].get("resumed_waiting_request") + confirmation_nonce = correlation_id.replace("-", "")[:6].upper() + confirmation_phrase = f"确认发起 {confirmation_nonce}" + confirming_actor_hash = hashlib.sha256( + context.actor_user_id.encode("utf-8") + ).hexdigest() + if not isinstance(resumed_request, Mapping): + summary = _feishu_approval_confirmation_summary(validated) + return None, { + "waiting_type": "user", + "correlation_id": correlation_id, + "reason": _FEISHU_APPROVAL_CONFIRMATION_REASON, + "question": ( + "即将发起正式飞书审批,提交后会进入审批流程。\n" + f"确认摘要:{summary}\n" + f"请整句回复“{confirmation_phrase}”继续;" + "回复其他内容不会提交," + "Agent 会按你的新指示继续处理。" + ), + "tool_call_id": call_id, + "arguments_hash": arguments_hash, + "confirming_actor_hash": confirming_actor_hash, + "confirmation_phrase": confirmation_phrase, + "discard_remaining_tool_calls_on_resume": True, + }, False + + expected_request = { + "reason": _FEISHU_APPROVAL_CONFIRMATION_REASON, + "correlation_id": correlation_id, + "tool_call_id": call_id, + "arguments_hash": arguments_hash, + "confirming_actor_hash": confirming_actor_hash, + } + if any( + resumed_request.get(key) != value + for key, value in expected_request.items() + ): + return ToolExecutionOutcome( + status="failed", + result_summary=( + "The Feishu approval was not created because the confirmed " + "proposal no longer matches the pending tool call." + ), + result_ref=None, + error_code="tool_confirmation_mismatch", + retryable=False, + metadata={"confirmation_status": "mismatch"}, + ), None, False + + reply = _feishu_approval_confirmation_reply(state) + trimmed_reply = reply.strip() if reply is not None else "" + if trimmed_reply == confirmation_phrase: + return None, None, True + if trimmed_reply.casefold() in _FEISHU_APPROVAL_CONFIRMATION_REJECT: + return ToolExecutionOutcome( + status="failed", + result_summary=( + "The user rejected the Feishu approval proposal; no approval " + "instance was created." + ), + result_ref=None, + error_code="tool_confirmation_rejected", + retryable=False, + metadata={"confirmation_status": "rejected"}, + ), None, False + return ToolExecutionOutcome( + status="failed", + result_summary=( + "The Feishu approval proposal did not receive an explicit " + "confirmation; no approval instance was created. Treat the user's " + "reply as a new instruction before preparing another proposal." + ), + result_ref=None, + error_code="tool_confirmation_not_granted", + retryable=False, + metadata={"confirmation_status": "not_granted"}, + ), None, False + + def _heartbeat_blocked_summary( agent: Agent, tool_name: str, @@ -1379,6 +1599,25 @@ async def execute_pending( "tool_not_enabled", f"tool {tool_name!r} is not enabled for this Agent", ) + ( + confirmation_outcome, + confirmation_wait, + confirmation_granted, + ) = ( + _feishu_approval_confirmation_gate( + state=state, + context=context, + call_id=call_id, + tool_name=tool_name, + arguments=arguments, + ) + ) + if confirmation_wait is not None: + return ToolStepResult( + messages=tuple(messages), + waiting_request=confirmation_wait, + pending_tool_calls=tool_calls[index:], + ) autonomy_outcome, approval_wait = ( await self._delete_autonomy_gate( state=state, @@ -1395,6 +1634,8 @@ async def execute_pending( waiting_request=approval_wait, pending_tool_calls=tool_calls[index:], ) + if autonomy_outcome is None: + autonomy_outcome = confirmation_outcome policy = _policy(tool_name) lease_owner = _tool_execution_lease_owner( context.command_id, @@ -1872,12 +2113,43 @@ async def execute_pending( context.run_id ) try: + executor_arguments = {} + if confirmation_granted: + runtime_authorization = ( + issue_feishu_approval_create_authorization( + run_id=context.run_id, + tool_call_id=call_id, + execution_id=str( + reservation.execution.id + ), + lease_owner=lease_owner, + tenant_id=context.tenant_id, + agent_id=str(agent.id), + actor_user_id=( + context.actor_user_id or "" + ), + arguments=arguments, + ) + ) + executor_arguments = { + "runtime_authorization": ( + runtime_authorization + ), + "runtime_run_id": context.run_id, + "runtime_tool_call_id": call_id, + "runtime_execution_id": str( + reservation.execution.id + ), + "runtime_lease_owner": lease_owner, + "runtime_tenant_id": context.tenant_id, + } raw_result = await self._tool_executor( tool_name, arguments, agent.id, context.actor_user_id and uuid.UUID(context.actor_user_id) or agent.creator_id, context.session_id or "", + **executor_arguments, ) finally: if agentbay_run_token is not None: diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 0224cdf65..6187db475 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -22,6 +22,7 @@ import multiprocessing as mp import os import queue +import re import tempfile import uuid import unicodedata @@ -29,8 +30,9 @@ from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Optional, Any, cast -import re +from urllib.parse import quote +import httpx from loguru import logger from sqlalchemy import select, or_ @@ -40,6 +42,8 @@ ) from app.database import async_session from app.models.agent import Agent as AgentModel +from app.models.agent_run import AgentRun +from app.models.agent_tool_execution import AgentToolExecution from app.models.audit import ChatMessage from app.models.chat_session import ChatSession from app.models.channel_config import ChannelConfig @@ -91,12 +95,30 @@ ToolExecutionOutcome, sanitize_tool_arguments, ) +from app.services.agent_runtime.feishu_approval_authorization import ( + FeishuApprovalCreateAuthorization, + feishu_approval_create_arguments_hash, + verify_feishu_approval_create_authorization, +) _settings = get_settings() WORKSPACE_ROOT = Path(_settings.STORAGE_LOCAL_ROOT or _settings.AGENT_DATA_DIR) TOOL_MATERIALIZE_MAX_FILE_BYTES = 10 * 1024 * 1024 TOOL_MATERIALIZE_MAX_TOTAL_BYTES = 100 * 1024 * 1024 +FEISHU_APPROVAL_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024 +FEISHU_APPROVAL_IMAGE_MAX_BYTES = 10 * 1024 * 1024 +FEISHU_APPROVAL_CODE_MAX_CHARS = 256 +FEISHU_APPROVAL_FORM_MAX_CHARS = 100_000 +FEISHU_APPROVAL_FORM_MAX_CONTROLS = 200 +_FEISHU_APPROVAL_IMAGE_MEDIA_TYPES = { + ".bmp": "image/bmp", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} TEMP_WORKSPACE_DEFAULT_PATHS = ["workspace", "memory", "skills", "focus.md", "soul.md", "HEARTBEAT.md"] MAX_EXEC_STDOUT_CAPTURE_BYTES = 1_000_000 MAX_EXEC_STDERR_CAPTURE_BYTES = 500_000 @@ -553,6 +575,9 @@ async def _get_scoped_agentbay_client( "feishu_drive_share", "feishu_drive_delete", "feishu_user_search", + "feishu_approval_definition_get", + "feishu_approval_file_upload", + "feishu_approval_create", "feishu_approval_query", "feishu_approval_get", "read_emails", @@ -1368,6 +1393,7 @@ async def _prepare_temp_workspace( agent_id: uuid.UUID, tenant_id: str | None = None, paths: list[str] | None = None, + max_file_bytes: int = TOOL_MATERIALIZE_MAX_FILE_BYTES, ) -> TempWorkspace: tmp = tempfile.TemporaryDirectory(prefix=f"clawith-agent-{str(agent_id)[:8]}-") temp_ws = Path(tmp.name) @@ -1382,7 +1408,15 @@ async def _prepare_temp_workspace( storage_key, normalized, is_enterprise = _tool_storage_key(agent_id, rel_path, tenant_id) if is_enterprise: continue - await _materialize_storage_path_with_budget(storage, storage_key, normalized, temp_ws, budget, manifest) + await _materialize_storage_path_with_budget( + storage, + storage_key, + normalized, + temp_ws, + budget, + manifest, + max_file_bytes=max_file_bytes, + ) return TempWorkspace( temp_dir=tmp, root=temp_ws, @@ -1400,10 +1434,12 @@ async def _materialize_storage_path_with_budget( local_root: Path, budget: dict, manifest: dict[str, TempWorkspaceManifestEntry], + *, + max_file_bytes: int = TOOL_MATERIALIZE_MAX_FILE_BYTES, ) -> None: if await storage.is_file(storage_key): version = await storage.get_version(storage_key) - if version.size > TOOL_MATERIALIZE_MAX_FILE_BYTES: + if version.size > max_file_bytes: return if budget["total"] + version.size > TOOL_MATERIALIZE_MAX_TOTAL_BYTES: return @@ -1427,7 +1463,15 @@ async def _materialize_storage_path_with_budget( (local_root / rel_path).mkdir(parents=True, exist_ok=True) for entry in await storage.list_dir(storage_key): child_rel = f"{rel_path.rstrip('/')}/{entry.name}" if rel_path else entry.name - await _materialize_storage_path_with_budget(storage, entry.key, child_rel, local_root, budget, manifest) + await _materialize_storage_path_with_budget( + storage, + entry.key, + child_rel, + local_root, + budget, + manifest, + max_file_bytes=max_file_bytes, + ) async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): @@ -1597,9 +1641,15 @@ async def _run_with_temp_workspace( *, paths: list[str] | None = None, sync_back: bool = False, + max_file_bytes: int = TOOL_MATERIALIZE_MAX_FILE_BYTES, ) -> str: """Materialize a temporary workspace for tools that require local files.""" - temp_workspace = await _prepare_temp_workspace(agent_id, tenant_id=tenant_id, paths=paths) + temp_workspace = await _prepare_temp_workspace( + agent_id, + tenant_id=tenant_id, + paths=paths, + max_file_bytes=max_file_bytes, + ) try: result = await runner(temp_workspace.root) if sync_back: @@ -1624,6 +1674,7 @@ async def _run_with_temp_workspace_outcome( paths: list[str] | None = None, sync_back: bool = False, sync_back_on_non_success: bool = False, + max_file_bytes: int = TOOL_MATERIALIZE_MAX_FILE_BYTES, ) -> ToolExecutionOutcome: """Run a typed local-content tool and preserve explicit sync facts.""" try: @@ -1631,6 +1682,7 @@ async def _run_with_temp_workspace_outcome( agent_id, tenant_id=tenant_id, paths=paths, + max_file_bytes=max_file_bytes, ) except Exception as exc: return _typed_failure( @@ -2512,6 +2564,13 @@ async def execute_builtin_tool_outcome( user_id: uuid.UUID, session_id: str = "", on_output=None, + *, + runtime_authorization: FeishuApprovalCreateAuthorization | None = None, + runtime_run_id: str | None = None, + runtime_tool_call_id: str | None = None, + runtime_execution_id: str | None = None, + runtime_lease_owner: str | None = None, + runtime_tenant_id: str | None = None, ) -> ToolExecutionOutcome | str: """Execute only explicitly migrated builtin branches as typed outcomes. @@ -2834,6 +2893,42 @@ async def execute_builtin_tool_outcome( return await _feishu_drive_delete_outcome(agent_id, arguments) if tool_name == "feishu_user_search": return await _feishu_user_search_outcome(agent_id, arguments) + if tool_name == "feishu_approval_definition_get": + return await _feishu_approval_definition_get_outcome( + agent_id, + arguments, + ) + if tool_name == "feishu_approval_file_upload": + file_path = arguments.get("file_path") + if not isinstance(file_path, str) or not file_path.strip(): + return _typed_failure( + "feishu_approval_file_upload requires file_path.", + "invalid_tool_arguments", + ) + tenant_id = await _get_agent_tenant_id(agent_id) + return await _run_with_temp_workspace_outcome( + agent_id, + tenant_id, + lambda temp_ws: _feishu_approval_file_upload_outcome( + agent_id, + temp_ws, + arguments, + ), + paths=[file_path], + max_file_bytes=FEISHU_APPROVAL_ATTACHMENT_MAX_BYTES, + ) + if tool_name == "feishu_approval_create": + return await _feishu_approval_create_outcome( + agent_id, + arguments, + actor_user_id=user_id, + authorization=runtime_authorization, + runtime_run_id=runtime_run_id, + runtime_tool_call_id=runtime_tool_call_id, + runtime_execution_id=runtime_execution_id, + runtime_lease_owner=runtime_lease_owner, + runtime_tenant_id=runtime_tenant_id, + ) if tool_name == "feishu_approval_query": return await _feishu_approval_query_outcome(agent_id, arguments) if tool_name == "feishu_approval_get": @@ -2975,6 +3070,11 @@ async def execute_tool( if tool_name == FINISH_TOOL_NAME: content = arguments.get("content", "") return content if isinstance(content, str) else str(content) + if tool_name == "feishu_approval_create": + return ( + "Feishu approval creation is blocked outside Durable Runtime " + "conversation confirmation." + ) _agent_tenant_id = await _get_agent_tenant_id(agent_id) @@ -3324,8 +3424,21 @@ async def execute_tool( result = await _feishu_calendar_update(agent_id, arguments) elif tool_name == "feishu_calendar_delete": result = await _feishu_calendar_delete(agent_id, arguments) - elif tool_name == "feishu_approval_create": - result = await _feishu_approval_create(agent_id, arguments) + elif tool_name == "feishu_approval_definition_get": + result = await _feishu_approval_definition_get(agent_id, arguments) + elif tool_name == "feishu_approval_file_upload": + file_path = arguments.get("file_path") + result = await _run_with_temp_workspace( + agent_id, + _agent_tenant_id, + lambda temp_ws: _feishu_approval_file_upload( + agent_id, + temp_ws, + arguments, + ), + paths=[file_path] if isinstance(file_path, str) and file_path else None, + max_file_bytes=FEISHU_APPROVAL_ATTACHMENT_MAX_BYTES, + ) elif tool_name == "feishu_approval_query": result = await _feishu_approval_query(agent_id, arguments) elif tool_name == "feishu_approval_get": @@ -16001,6 +16114,98 @@ async def _feishu_calendar_delete(agent_id: uuid.UUID, arguments: dict) -> str: "timeline": "timeline", "comments": "comment_list", } +_FEISHU_PROVIDER_RESPONSE_MAX_BYTES = 8192 + + +def _feishu_provider_receipt( + response: object, +) -> tuple[int | None, object | None, bool, dict[str, object]]: + """Capture one bounded Provider response before classifying it.""" + status_code = getattr(response, "status_code", None) + if isinstance(status_code, bool) or not isinstance(status_code, int): + status_code = None + try: + payload = response.json() # type: ignore[attr-defined] + payload_is_json = True + except Exception: + payload = None + payload_is_json = False + + if payload_is_json: + try: + serialized = json.dumps( + payload, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + payload_is_json = False + else: + if len(serialized.encode("utf-8")) <= _FEISHU_PROVIDER_RESPONSE_MAX_BYTES: + response_body: object = json.loads(serialized) + else: + preview = serialized.encode("utf-8")[ + : _FEISHU_PROVIDER_RESPONSE_MAX_BYTES - 128 + ].decode("utf-8", errors="ignore") + response_body = {"truncated": True, "preview": preview} + if not payload_is_json: + raw_text = getattr(response, "text", "") + if not isinstance(raw_text, str): + raw_text = str(raw_text) + encoded = raw_text.encode("utf-8") + response_body = ( + raw_text + if len(encoded) <= _FEISHU_PROVIDER_RESPONSE_MAX_BYTES + else encoded[: _FEISHU_PROVIDER_RESPONSE_MAX_BYTES].decode( + "utf-8", + errors="ignore", + ) + ) + + metadata: dict[str, object] = { + "provider_response_body": response_body, + } + if status_code is not None: + metadata["provider_http_status"] = status_code + if isinstance(payload, Mapping): + code = payload.get("code") + if isinstance(code, int) and not isinstance(code, bool): + metadata["provider_code"] = code + msg = payload.get("msg") + if isinstance(msg, str): + metadata["provider_msg"] = msg + return status_code, payload, payload_is_json, metadata + + +def _feishu_provider_error_summary( + operation: str, + prefix: str, + metadata: Mapping[str, object], +) -> str: + """Expose the bounded Feishu receipt so the model can repair the request.""" + facts: list[str] = [] + status_code = metadata.get("provider_http_status") + if isinstance(status_code, int): + facts.append(f"HTTP {status_code}") + code = metadata.get("provider_code") + if isinstance(code, int): + facts.append(f"code {code}") + msg = metadata.get("provider_msg") + if isinstance(msg, str) and msg: + facts.append(f"msg {msg}") + body = metadata.get("provider_response_body") + try: + body_text = json.dumps( + body, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + body_text = str(body) + facts.append(f"response {body_text}") + return f"Feishu {prefix} {operation}: " + "; ".join(facts) + "." def _feishu_approval_read_response( @@ -16008,62 +16213,107 @@ def _feishu_approval_read_response( operation: str, ) -> tuple[Mapping | None, ToolExecutionOutcome | None]: """Validate one approval read response without losing HTTP status facts.""" - status_code = getattr(response, "status_code", None) - if not isinstance(status_code, int) or isinstance(status_code, bool): + status_code, payload, payload_is_json, receipt = _feishu_provider_receipt( + response + ) + if status_code is None: return None, _typed_failure( - f"Feishu {operation} returned no readable HTTP status.", + _feishu_provider_error_summary( + operation, + "returned no readable HTTP status for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) if status_code == 429 or status_code >= 500: return None, _typed_failure( - f"Feishu {operation} is temporarily unavailable.", + _feishu_provider_error_summary( + operation, + "temporarily rejected", + receipt, + ), f"feishu_{operation}_http_retryable", retryable=True, + metadata=receipt, ) if 400 <= status_code < 500: return None, _typed_failure( - f"Feishu rejected {operation}.", + _feishu_provider_error_summary( + operation, + "rejected", + receipt, + ), f"feishu_{operation}_http_rejected", + metadata=receipt, ) if not 200 <= status_code < 300: return None, _typed_failure( - f"Feishu {operation} returned an unexpected HTTP status.", + _feishu_provider_error_summary( + operation, + "returned an unexpected status for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) - try: - payload = response.json() - except Exception: + if not payload_is_json: return None, _typed_failure( - f"Feishu {operation} returned unreadable JSON.", + _feishu_provider_error_summary( + operation, + "returned unreadable JSON for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) if not isinstance(payload, Mapping): return None, _typed_failure( - f"Feishu {operation} returned an invalid response.", + _feishu_provider_error_summary( + operation, + "returned an invalid response for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) code = payload.get("code") if isinstance(code, bool) or not isinstance(code, int): return None, _typed_failure( - f"Feishu {operation} returned no valid business code.", + _feishu_provider_error_summary( + operation, + "returned no valid business code for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) if code != 0: return None, _typed_failure( - f"Feishu rejected {operation}.", + _feishu_provider_error_summary( + operation, + "rejected", + receipt, + ), f"feishu_{operation}_rejected", + metadata=receipt, ) data = payload.get("data") if not isinstance(data, Mapping): return None, _typed_failure( - f"Feishu {operation} returned an invalid data object.", + _feishu_provider_error_summary( + operation, + "returned an invalid data object for", + receipt, + ), f"feishu_{operation}_response_invalid", retryable=True, + metadata=receipt, ) return data, None @@ -16087,6 +16337,360 @@ def _bounded_feishu_json(payload: Mapping, *, max_bytes: int = 8192) -> str: return '{"truncated":true}' +async def _feishu_approval_definition_get_outcome( + agent_id: uuid.UUID, + arguments: dict, +) -> ToolExecutionOutcome: + """Read one bounded section of the current approval definition.""" + approval_code = arguments.get("approval_code") + section = arguments.get("section", "summary") + offset = arguments.get("offset", 0) + limit = arguments.get("limit", 20) + if not isinstance(approval_code, str) or not approval_code.strip(): + return _typed_failure( + "feishu_approval_definition_get requires approval_code.", + "invalid_tool_arguments", + ) + if not isinstance(section, str) or section not in { + "summary", + "form", + "nodes", + }: + return _typed_failure( + "feishu_approval_definition_get section is invalid.", + "invalid_tool_arguments", + ) + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or offset < 0 + or isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= 50 + ): + return _typed_failure( + "feishu_approval_definition_get requires offset >= 0 and limit 1..50.", + "invalid_tool_arguments", + ) + + token, token_error = await _feishu_access_token_outcome(agent_id) + if token_error is not None or token is None: + return token_error or _typed_failure( + "Feishu credentials are unavailable.", + "feishu_channel_not_configured", + ) + stable_code = approval_code.strip() + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + "https://open.feishu.cn/open-apis/approval/v4/approvals/" + + quote(stable_code, safe=""), + headers={"Authorization": f"Bearer {token}"}, + ) + except Exception as exc: + return _feishu_read_exception_outcome("approval_definition_get", exc) + + data, response_error = _feishu_approval_read_response( + response, + "approval_definition_get", + ) + if response_error is not None or data is None: + return response_error or _typed_failure( + "Feishu approval_definition_get returned no data.", + "feishu_approval_definition_get_response_invalid", + retryable=True, + ) + + raw_form = data.get("form", []) + if isinstance(raw_form, str): + try: + raw_form = json.loads(raw_form) + except (TypeError, ValueError): + return _typed_failure( + "Feishu approval_definition_get returned an invalid form.", + "feishu_approval_definition_get_response_invalid", + retryable=True, + ) + raw_nodes = data.get("node_list", []) + if not isinstance(raw_form, list) or not isinstance(raw_nodes, list): + return _typed_failure( + "Feishu approval_definition_get returned invalid form or node structure.", + "feishu_approval_definition_get_response_invalid", + retryable=True, + ) + + if section == "summary": + summary: dict[str, object] = { + "approval_code": stable_code, + "form_control_count": len(raw_form), + "node_count": len(raw_nodes), + } + for key in ("approval_name", "status"): + value = data.get(key) + if isinstance(value, str) and value: + summary[key] = value + return _typed_success( + _bounded_feishu_json(summary), + result_ref=stable_code, + metadata={"section": "summary"}, + ) + + items = raw_form if section == "form" else raw_nodes + selected = items[offset : offset + limit] + next_offset = offset + len(selected) + has_more = next_offset < len(items) + return _typed_success( + _bounded_feishu_json( + { + "approval_code": stable_code, + "section": section, + "offset": offset, + "returned_count": len(selected), + "items": selected, + } + ), + result_ref=stable_code, + metadata={ + "section": section, + "offset": offset, + "returned_count": len(selected), + "has_more": has_more, + "next_offset": next_offset if has_more else None, + }, + ) + + +def _feishu_approval_file_path( + workspace_root: Path, + file_path: object, +) -> tuple[Path | None, ToolExecutionOutcome | None]: + """Resolve one regular workspace file without following an escape path.""" + if not isinstance(file_path, str) or not file_path.strip(): + return None, _typed_failure( + "feishu_approval_file_upload requires file_path.", + "invalid_tool_arguments", + ) + relative_text = file_path.strip() + relative_path = Path(relative_text) + if ( + len(relative_text.encode("utf-8")) > 1024 + or relative_path.is_absolute() + or ".." in relative_path.parts + or "\\" in relative_text + ): + return None, _typed_failure( + "Approval file_path must be a contained workspace-relative path.", + "feishu_approval_file_path_rejected", + ) + root = workspace_root.resolve() + unresolved = root / relative_path + try: + resolved = unresolved.resolve(strict=True) + resolved.relative_to(root) + except (FileNotFoundError, OSError, ValueError): + return None, _typed_failure( + "The approval upload source does not exist inside the workspace.", + "feishu_approval_file_not_found", + ) + if unresolved.is_symlink() or not resolved.is_file(): + return None, _typed_failure( + "The approval upload source must be a regular workspace file.", + "feishu_approval_file_rejected", + ) + if not resolved.suffix: + return None, _typed_failure( + "The approval upload source name must include a file extension.", + "feishu_approval_file_type_rejected", + ) + return resolved, None + + +async def _feishu_approval_file_upload_outcome( + agent_id: uuid.UUID, + workspace_root: Path, + arguments: dict, +) -> ToolExecutionOutcome: + """Upload one validated workspace file and settle its Provider receipt.""" + file_type = arguments.get("file_type") + if file_type not in {"image", "attachment"}: + return _typed_failure( + "feishu_approval_file_upload file_type must be image or attachment.", + "invalid_tool_arguments", + ) + file_path, path_error = _feishu_approval_file_path( + workspace_root, + arguments.get("file_path"), + ) + if path_error is not None or file_path is None: + return path_error or _typed_failure( + "The approval upload source is unavailable.", + "feishu_approval_file_not_found", + ) + suffix = file_path.suffix.lower() + if file_type == "image" and suffix not in _FEISHU_APPROVAL_IMAGE_MEDIA_TYPES: + return _typed_failure( + "Approval image uploads require a BMP, GIF, JPEG, PNG, or WebP file.", + "feishu_approval_file_type_rejected", + ) + try: + size = file_path.stat().st_size + except OSError: + return _typed_failure( + "The approval upload source could not be inspected.", + "feishu_approval_file_rejected", + ) + max_bytes = ( + FEISHU_APPROVAL_IMAGE_MAX_BYTES + if file_type == "image" + else FEISHU_APPROVAL_ATTACHMENT_MAX_BYTES + ) + if size <= 0 or size > max_bytes: + return _typed_failure( + f"Approval {file_type} must be non-empty and no larger than {max_bytes // (1024 * 1024)} MiB.", + "feishu_approval_file_size_rejected", + ) + try: + content = file_path.read_bytes() + except OSError: + return _typed_failure( + "The approval upload source could not be read.", + "feishu_approval_file_rejected", + ) + if len(content) != size: + return _typed_failure( + "The approval upload source changed while it was being read.", + "feishu_approval_file_rejected", + ) + + token, token_error = await _feishu_access_token_outcome(agent_id) + if token_error is not None or token is None: + return token_error or _typed_failure( + "Feishu credentials are unavailable.", + "feishu_channel_not_configured", + ) + media_type = _FEISHU_APPROVAL_IMAGE_MEDIA_TYPES.get( + suffix, + "application/octet-stream", + ) + receipt_metadata = { + "file_name": file_path.name, + "file_type": file_type, + "size_bytes": size, + } + try: + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + "https://www.feishu.cn/approval/openapi/v2/file/upload", + headers={"Authorization": f"Bearer {token}"}, + data={"name": file_path.name, "type": file_type}, + files={"content": (file_path.name, content, media_type)}, + ) + except Exception as exc: + return _feishu_write_exception_outcome( + "approval_file_upload", + exc, + metadata=receipt_metadata, + ) + + status_code, payload, payload_is_json, provider_receipt = ( + _feishu_provider_receipt(response) + ) + failure_metadata = {**receipt_metadata, **provider_receipt} + if status_code is None: + return _typed_unknown( + _feishu_provider_error_summary( + "approval_file_upload", + "returned no HTTP receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=failure_metadata, + ) + if status_code == 429 or status_code >= 500: + return _typed_unknown( + _feishu_provider_error_summary( + "approval_file_upload", + "returned an uncertain result for", + provider_receipt, + ) + + " It may have taken effect; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=failure_metadata, + ) + if not 200 <= status_code < 300: + return _typed_failure( + _feishu_provider_error_summary( + "approval_file_upload", + "rejected", + provider_receipt, + ), + "feishu_approval_file_upload_rejected", + metadata=failure_metadata, + ) + if not payload_is_json: + return _typed_unknown( + _feishu_provider_error_summary( + "approval_file_upload", + "returned an unreadable receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=failure_metadata, + ) + if not isinstance(payload, Mapping): + return _typed_unknown( + _feishu_provider_error_summary( + "approval_file_upload", + "returned an invalid receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=failure_metadata, + ) + code = payload.get("code") + if isinstance(code, bool) or not isinstance(code, int): + return _typed_unknown( + _feishu_provider_error_summary( + "approval_file_upload", + "returned no business receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=failure_metadata, + ) + if code != 0: + return _typed_failure( + _feishu_provider_error_summary( + "approval_file_upload", + "rejected", + provider_receipt, + ), + "feishu_approval_file_upload_rejected", + metadata=failure_metadata, + ) + data = payload.get("data") + file_code = ( + str(data.get("code") or "").strip() + if isinstance(data, Mapping) + else "" + ) + if not file_code: + return _typed_unknown( + "Feishu accepted approval_file_upload but returned no file code; reconcile before retrying.", + "feishu_approval_file_upload_receipt_missing", + metadata=receipt_metadata, + ) + return _typed_success( + _bounded_feishu_json({**receipt_metadata, "file_code": file_code}), + result_ref=file_code, + metadata=receipt_metadata, + ) + + async def _feishu_user_search_outcome( agent_id: uuid.UUID, arguments: dict, @@ -16465,48 +17069,290 @@ async def _feishu_approval_get_outcome( ) -async def _feishu_approval_create_outcome( - agent_id: uuid.UUID, +def validate_feishu_approval_create_arguments( arguments: dict, -) -> ToolExecutionOutcome: - """Hidden external-write adapter retained behind the future confirmation gate.""" - import httpx - +) -> tuple[dict[str, object] | None, ToolExecutionOutcome | None]: + """Validate approval-create arguments without credentials or Provider I/O.""" + allowed_keys = { + "approval_code", + "target_member_id", + "form_data", + "department_id", + "uuid", + } + if any(key not in allowed_keys for key in arguments): + return None, _typed_failure( + "feishu_approval_create received unsupported arguments.", + "invalid_tool_arguments", + ) approval_code = arguments.get("approval_code") target_member_id = arguments.get("target_member_id") form_data = arguments.get("form_data") if not ( isinstance(approval_code, str) and approval_code.strip() + and len(approval_code.strip()) <= FEISHU_APPROVAL_CODE_MAX_CHARS and isinstance(target_member_id, str) and target_member_id.strip() and isinstance(form_data, str) and form_data.strip() + and len(form_data) <= FEISHU_APPROVAL_FORM_MAX_CHARS ): - return _typed_failure( + return None, _typed_failure( "feishu_approval_create requires approval_code, target_member_id, and form_data.", "invalid_tool_arguments", ) + try: + normalized_target_member_id = str(uuid.UUID(target_member_id.strip())) + except (TypeError, ValueError): + return None, _typed_failure( + "feishu_approval_create target_member_id must be a UUID.", + "invalid_tool_arguments", + ) try: parsed_form = json.loads(form_data) except (TypeError, ValueError): - return _typed_failure( + return None, _typed_failure( "feishu_approval_create form_data must be a JSON array.", "invalid_tool_arguments", ) - if not isinstance(parsed_form, list): + if ( + not isinstance(parsed_form, list) + or len(parsed_form) > FEISHU_APPROVAL_FORM_MAX_CONTROLS + ): + return None, _typed_failure( + "feishu_approval_create form_data must be a bounded JSON array.", + "invalid_tool_arguments", + ) + for control in parsed_form: + if not isinstance(control, Mapping) or any( + key not in control for key in ("id", "type", "value") + ): + return None, _typed_failure( + "feishu_approval_create form_data controls require id, type, and value.", + "invalid_tool_arguments", + ) + if not all( + isinstance(control.get(key), str) and control.get(key) + for key in ("id", "type") + ): + return None, _typed_failure( + "feishu_approval_create form_data control id and type must be non-empty strings.", + "invalid_tool_arguments", + ) + if control.get("type") in {"attachmentV2", "image", "imageV2"}: + value = control.get("value") + if ( + not isinstance(value, list) + or not value + or not all( + isinstance(file_code, str) and file_code.strip() + for file_code in value + ) + ): + return None, _typed_failure( + "feishu_approval_create attachment and image controls " + "require a non-empty array of string file codes.", + "invalid_tool_arguments", + ) + + optional_strings: dict[str, str] = {} + for key in ("department_id", "uuid"): + value = arguments.get(key) + if value is None: + continue + if ( + not isinstance(value, str) + or not value.strip() + or len(value.strip()) > (64 if key == "uuid" else 128) + ): + return None, _typed_failure( + f"feishu_approval_create {key} must be a non-empty string when provided.", + "invalid_tool_arguments", + ) + optional_strings[key] = value.strip() + return { + "approval_code": approval_code.strip(), + "target_member_id": normalized_target_member_id, + "form_data": form_data, + "parsed_form": parsed_form, + "optional_strings": optional_strings, + }, None + + +async def _consume_feishu_approval_create_authorization( + authorization: FeishuApprovalCreateAuthorization | None, + *, + agent_id: uuid.UUID, + actor_user_id: uuid.UUID, + arguments: Mapping[str, object], + runtime_run_id: str | None, + runtime_tool_call_id: str | None, + runtime_execution_id: str | None, + runtime_lease_owner: str | None, + runtime_tenant_id: str | None, +) -> ToolExecutionOutcome | None: + """Atomically consume confirmation against the live Tool Ledger row.""" + if not all( + isinstance(value, str) and value.strip() + for value in ( + runtime_run_id, + runtime_tool_call_id, + runtime_execution_id, + runtime_lease_owner, + runtime_tenant_id, + ) + ): return _typed_failure( - "feishu_approval_create form_data must be a JSON array.", + "Feishu approval creation requires a live Runtime tool receipt.", + "tool_confirmation_required", + ) + assert isinstance(runtime_run_id, str) + assert isinstance(runtime_tool_call_id, str) + assert isinstance(runtime_execution_id, str) + assert isinstance(runtime_lease_owner, str) + assert isinstance(runtime_tenant_id, str) + try: + run_id = uuid.UUID(runtime_run_id) + execution_id = uuid.UUID(runtime_execution_id) + tenant_id = uuid.UUID(runtime_tenant_id) + arguments_hash = feishu_approval_create_arguments_hash(arguments) + except (TypeError, ValueError): + return _typed_failure( + "Feishu approval creation received an invalid Runtime receipt.", + "tool_confirmation_required", + ) + if not verify_feishu_approval_create_authorization( + authorization, + run_id=str(run_id), + tool_call_id=runtime_tool_call_id, + execution_id=str(execution_id), + lease_owner=runtime_lease_owner, + tenant_id=str(tenant_id), + agent_id=str(agent_id), + actor_user_id=str(actor_user_id), + arguments=arguments, + ): + return _typed_failure( + "Feishu approval creation requires a valid Runtime confirmation proof.", + "tool_confirmation_required", + ) + try: + async with async_session() as db: + async with db.begin(): + result = await db.execute( + select(AgentToolExecution) + .join( + AgentRun, + ( + (AgentRun.id == AgentToolExecution.run_id) + & ( + AgentRun.tenant_id + == AgentToolExecution.tenant_id + ) + ), + ) + .where( + AgentToolExecution.id == execution_id, + AgentToolExecution.tenant_id == tenant_id, + AgentToolExecution.run_id == run_id, + AgentToolExecution.tool_call_id + == runtime_tool_call_id, + AgentToolExecution.tool_name + == "feishu_approval_create", + AgentRun.agent_id == agent_id, + AgentRun.tenant_id == tenant_id, + AgentRun.origin_user_id == actor_user_id, + AgentRun.source_type == "chat", + ) + .with_for_update() + ) + execution = result.scalar_one_or_none() + metadata = ( + dict(execution.result_metadata or {}) + if execution is not None + else {} + ) + if ( + execution is None + or execution.status != "started" + or execution.lease_owner != runtime_lease_owner + or execution.arguments_hash != arguments_hash + or execution.effect != "external_write" + or execution.retry_policy != "never" + or metadata.get( + "feishu_approval_confirmation_consumed" + ) + is True + ): + return _typed_failure( + "Feishu approval confirmation is stale or already consumed.", + "tool_confirmation_required", + ) + metadata["feishu_approval_confirmation_consumed"] = True + metadata["feishu_approval_confirmation_proof"] = ( + hashlib.sha256( + authorization.signature.encode("utf-8") + ).hexdigest() + if authorization is not None + else None + ) + execution.result_metadata = metadata + except Exception: + return _typed_failure( + "Feishu approval confirmation receipt could not be consumed.", + "tool_confirmation_required", + ) + return None + + +async def _feishu_approval_create_outcome( + agent_id: uuid.UUID, + arguments: dict, + *, + actor_user_id: uuid.UUID, + authorization: FeishuApprovalCreateAuthorization | None, + runtime_run_id: str | None, + runtime_tool_call_id: str | None, + runtime_execution_id: str | None, + runtime_lease_owner: str | None, + runtime_tenant_id: str | None, +) -> ToolExecutionOutcome: + """Create one approval instance after the Runtime confirmation gate.""" + authorization_error = await _consume_feishu_approval_create_authorization( + authorization, + agent_id=agent_id, + actor_user_id=actor_user_id, + arguments=arguments, + runtime_run_id=runtime_run_id, + runtime_tool_call_id=runtime_tool_call_id, + runtime_execution_id=runtime_execution_id, + runtime_lease_owner=runtime_lease_owner, + runtime_tenant_id=runtime_tenant_id, + ) + if authorization_error is not None: + return authorization_error + validated, validation_error = validate_feishu_approval_create_arguments( + arguments + ) + if validation_error is not None or validated is None: + return validation_error or _typed_failure( + "feishu_approval_create arguments are invalid.", "invalid_tool_arguments", ) + approval_code = cast(str, validated["approval_code"]) + target_member_id = cast(str, validated["target_member_id"]) + form_data = cast(str, validated["form_data"]) + optional_strings = cast(dict[str, str], validated["optional_strings"]) try: async with async_session() as db: target, target_error = await _resolve_roster_human_target( db, agent_id, - target_member_id=target_member_id.strip(), + target_member_id=target_member_id, provider_type="feishu", + require_platform_user=True, require_provider_identity=True, ) except Exception as exc: @@ -16524,6 +17370,11 @@ async def _feishu_approval_create_outcome( "The approval applicant is not a Feishu member.", "feishu_approval_target_provider_mismatch", ) + if getattr(target.member, "user_id", None) != actor_user_id: + return _typed_failure( + "The approval applicant must be the authenticated confirming user.", + "feishu_approval_applicant_mismatch", + ) provider_user_id = str( getattr(target.member, "external_id", "") or "" ).strip() @@ -16539,16 +17390,18 @@ async def _feishu_approval_create_outcome( "Feishu credentials are unavailable.", "feishu_channel_not_configured", ) + request_body: dict[str, object] = { + "approval_code": approval_code, + "user_id": provider_user_id, + "form": form_data, + **optional_strings, + } try: async with httpx.AsyncClient(timeout=20) as client: response = await client.post( "https://open.feishu.cn/open-apis/approval/v4/instances", headers={"Authorization": f"Bearer {token}"}, - json={ - "approval_code": approval_code.strip(), - "user_id": provider_user_id, - "form": form_data, - }, + json=request_body, ) except Exception as exc: return _feishu_write_exception_outcome( @@ -16556,44 +17409,92 @@ async def _feishu_approval_create_outcome( exc, ) - status_code = getattr(response, "status_code", None) - if not isinstance(status_code, int) or isinstance(status_code, bool): + status_code, payload, payload_is_json, provider_receipt = ( + _feishu_provider_receipt(response) + ) + if status_code is None: return _typed_unknown( - "Feishu approval_create returned no readable HTTP receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_create", + "returned no readable HTTP receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_create_outcome_unknown", + metadata=provider_receipt, ) if status_code == 429 or status_code >= 500: return _typed_unknown( - "Feishu approval_create may have taken effect; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_create", + "returned an uncertain result for", + provider_receipt, + ) + + " It may have taken effect; reconcile before retrying.", "feishu_approval_create_outcome_unknown", + metadata=provider_receipt, ) if 400 <= status_code < 500: return _typed_failure( - "Feishu rejected approval_create.", + _feishu_provider_error_summary( + "approval_create", + "rejected", + provider_receipt, + ), "feishu_approval_create_rejected", + metadata=provider_receipt, ) - try: - payload = response.json() - except Exception: + if not payload_is_json: return _typed_unknown( - "Feishu approval_create returned an unreadable receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_create", + "returned an unreadable receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_create_outcome_unknown", + metadata=provider_receipt, ) if not isinstance(payload, Mapping): return _typed_unknown( - "Feishu approval_create returned an invalid receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_create", + "returned an invalid receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_create_outcome_unknown", + metadata=provider_receipt, ) code = payload.get("code") if isinstance(code, bool) or not isinstance(code, int): return _typed_unknown( - "Feishu approval_create returned no business receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_create", + "returned no business receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_create_outcome_unknown", + metadata=provider_receipt, + ) + reconciliation_ref = optional_strings.get("uuid") + if code == 60012: + return _typed_unknown( + "Feishu reported an approval_create uuid conflict; reconcile the existing instance before retrying.", + "feishu_approval_create_uuid_conflict", + result_ref=reconciliation_ref, + metadata=provider_receipt, ) if code != 0: return _typed_failure( - "Feishu rejected approval_create.", + _feishu_provider_error_summary( + "approval_create", + "rejected", + provider_receipt, + ), "feishu_approval_create_rejected", + metadata=provider_receipt, ) data = payload.get("data") instance_code = ( @@ -16605,19 +17506,55 @@ async def _feishu_approval_create_outcome( return _typed_unknown( "Feishu accepted approval_create but returned no instance receipt; reconcile before retrying.", "feishu_approval_create_receipt_missing", + result_ref=reconciliation_ref, ) + instance_link = ( + str(data.get("instance_link") or "").strip() + if isinstance(data, Mapping) + else "" + ) return _typed_success( f"Feishu approval instance {instance_code} was created.", result_ref=instance_code, + metadata={"instance_link": instance_link} if instance_link else {}, ) async def _feishu_approval_create(agent_id: uuid.UUID, arguments: dict) -> str: - """Legacy display adapter; Durable Runtime keeps this write hidden.""" - outcome = await _feishu_approval_create_outcome(agent_id, arguments) + """Fail closed: approval creation requires a Runtime-issued proof.""" + del agent_id, arguments + return ( + "Feishu approval creation is blocked outside Durable Runtime " + "conversation confirmation." + ) + + +async def _feishu_approval_definition_get( + agent_id: uuid.UUID, + arguments: dict, +) -> str: + """Legacy display adapter for a bounded approval definition read.""" + outcome = await _feishu_approval_definition_get_outcome(agent_id, arguments) + return _legacy_tool_outcome_text( + outcome, + fallback="Feishu approval definition read returned no summary.", + ) + + +async def _feishu_approval_file_upload( + agent_id: uuid.UUID, + workspace_root: Path, + arguments: dict, +) -> str: + """Legacy display adapter for a typed approval file upload.""" + outcome = await _feishu_approval_file_upload_outcome( + agent_id, + workspace_root, + arguments, + ) return _legacy_tool_outcome_text( outcome, - fallback="Feishu approval creation returned no summary.", + fallback="Feishu approval file upload returned no summary.", ) diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 83f6b0054..3a9e60f55 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -2496,10 +2496,88 @@ "config": {}, "config_schema": {}, }, + { + "name": "feishu_approval_definition_get", + "display_name": "Feishu Approval Definition Get", + "description": ( + "读取飞书审批定义的当前表单或流程节点结构," + "用于构造后续审批实例请求。" + ), + "category": "feishu", + "icon": "🧩", + "is_default": False, + "parameters_schema": { + "type": "object", + "properties": { + "approval_code": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "审批定义的唯一代码 (approval_code)。", + }, + "section": { + "type": "string", + "enum": ["summary", "form", "nodes"], + "default": "summary", + "description": "读取定义摘要、表单控件或流程节点。", + }, + "offset": { + "type": "integer", + "default": 0, + "minimum": 0, + "description": "form 或 nodes 区段的零基偏移量。", + }, + "limit": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 50, + "description": "form 或 nodes 区段本次最多返回的项目数。", + }, + }, + "required": ["approval_code"], + "additionalProperties": False, + }, + "config": {}, + "config_schema": {}, + }, + { + "name": "feishu_approval_file_upload", + "display_name": "Feishu Approval File Upload", + "description": ( + "将一个工作区文件上传到飞书审批系统,返回可写入 image 或 " + "attachment 表单控件的文件 code。" + ), + "category": "feishu", + "icon": "📎", + "is_default": False, + "parameters_schema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "minLength": 1, + "description": "工作区相对路径,例如 workspace/reimbursements/receipt.pdf。", + }, + "file_type": { + "type": "string", + "enum": ["image", "attachment"], + "description": "必须与审批定义中的目标控件类型一致。", + }, + }, + "required": ["file_path", "file_type"], + "additionalProperties": False, + }, + "config": {}, + "config_schema": {}, + }, { "name": "feishu_approval_create", "display_name": "Feishu Approval Create", - "description": "发起一个飞书审批流实例。该外部写入当前仅保留兼容合同,Durable Runtime 在确认门禁接入前不会向模型暴露。", + "description": ( + "发起一个飞书审批流实例。先读取审批定义," + "并按需上传表单中的图片或附件。" + ), "category": "feishu", "icon": "📝", "is_default": False, @@ -2509,6 +2587,7 @@ "approval_code": { "type": "string", "minLength": 1, + "maxLength": 256, "description": "审批定义的唯一代码 (approval_code)。", }, "target_member_id": { @@ -2519,8 +2598,27 @@ "form_data": { "type": "string", "minLength": 2, + "maxLength": 100000, "description": "表单字段数组的 JSON 字符串。该字段属于敏感参数。", }, + "department_id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": ( + "可选的审批发起人所属 department_id;" + "多部门成员需要显式指定。" + ), + }, + "uuid": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": ( + "可选的租户内幂等键;" + "同一个 uuid 只能成功创建一个审批实例。" + ), + }, }, "required": ["approval_code", "target_member_id", "form_data"], "additionalProperties": False, @@ -3700,6 +3798,7 @@ "feishu_doc_read", "feishu_calendar_list", "feishu_user_search", + "feishu_approval_definition_get", "feishu_approval_query", "feishu_approval_get", "read_emails", diff --git a/backend/tests/test_agent_runtime_chat_intake.py b/backend/tests/test_agent_runtime_chat_intake.py index 7c0ec3d91..723989cb6 100644 --- a/backend/tests/test_agent_runtime_chat_intake.py +++ b/backend/tests/test_agent_runtime_chat_intake.py @@ -436,7 +436,8 @@ async def test_chat_resume_persists_explicit_correlation_with_the_user_message() user=user, session=session, model=model, - content="Yes, continue", + content="[发送者: Alice] 确认发起 ABC123", + display_content="确认发起 ABC123", message_id=message_id, resume_run_id=run_id, resume_correlation_id="confirm-7", @@ -458,7 +459,8 @@ async def test_chat_resume_persists_explicit_correlation_with_the_user_message() "correlation_id": "confirm-7", "payload": { "message_id": str(message_id), - "content": "Yes, continue", + "content": "[发送者: Alice] 确认发起 ABC123", + "confirmation_text": "确认发起 ABC123", }, } assert waiting_run.delivery_target == { @@ -835,7 +837,11 @@ async def test_direct_resume_exact_retry_remains_idempotent_after_apply() -> Non payload={ "resume_type": "user_input", "correlation_id": "confirm-1", - "payload": {"message_id": str(message_id), "content": "Continue"}, + "payload": { + "message_id": str(message_id), + "content": "Continue", + "confirmation_text": "Continue", + }, }, actor_user_id=user.id, idempotency_key=f"resume:chat:{message_id}", diff --git a/backend/tests/test_agent_runtime_node_executor.py b/backend/tests/test_agent_runtime_node_executor.py index 9c112d786..8efb58dc8 100644 --- a/backend/tests/test_agent_runtime_node_executor.py +++ b/backend/tests/test_agent_runtime_node_executor.py @@ -941,17 +941,27 @@ async def test_user_resume_with_pending_tool_returns_to_tool_before_model() -> N _context(run_id, executor, "command-reconcile"), resume_value={ "resume_type": "user_input", - "payload": {"content": "The write did not take effect."}, + "payload": { + "content": "The write did not take effect.", + "confirmation_text": "The write did not take effect.", + }, }, ) assert update["lifecycle"]["status"] == "running" assert update["lifecycle"]["next_route"] == "tool" assert update["lifecycle"]["pending_tool_calls"] == [pending_call] + assert update["lifecycle"]["resumed_waiting_request"] == { + "waiting_type": "user", + "correlation_id": "tool-confirm-1", + } assert "messages" not in update assert update["lifecycle"]["deferred_resume_messages"][0]["content"] == ( "The write did not take effect." ) + assert update["lifecycle"]["deferred_resume_messages"][0][ + "runtime_confirmation_text" + ] == "The write did not take effect." tool_state = cast( RuntimeGraphState, @@ -968,6 +978,88 @@ async def test_user_resume_with_pending_tool_returns_to_tool_before_model() -> N "user", ] assert tool_update["lifecycle"]["deferred_resume_messages"] == [] + assert "resumed_waiting_request" not in tool_update["lifecycle"] + + +@pytest.mark.asyncio +async def test_confirmation_resume_discards_unconfirmed_tail_calls() -> None: + run_id = uuid.uuid4() + approval_call: JsonObject = { + "id": "call-approval", + "type": "function", + "function": { + "name": "feishu_approval_create", + "arguments": "{}", + }, + } + unconfirmed_tail: JsonObject = { + "id": "call-tail", + "type": "function", + "function": { + "name": "send_channel_message", + "arguments": "{}", + }, + } + tools = ToolService( + ToolStepResult( + messages=( + { + "id": "tool-result-approval", + "role": "tool", + "tool_call_id": "call-approval", + "name": "feishu_approval_create", + "content": "Approval was not created.", + "execution_status": "failed", + }, + ), + ) + ) + executor = _executor(ModelService(), tools=tools) + state = _state(run_id) + state["lifecycle"].update( + { + "status": "waiting_user", + "next_route": "wait", + "pending_tool_calls": [approval_call, unconfirmed_tail], + "waiting_request": { + "waiting_type": "user", + "correlation_id": "approval-confirm-1", + "tool_call_id": "call-approval", + "discard_remaining_tool_calls_on_resume": True, + }, + } + ) + + wait_update = await executor.execute( + "wait", + state, + _context(run_id, executor, "command-confirm"), + resume_value={ + "resume_type": "user_input", + "payload": { + "content": "取消", + "confirmation_text": "取消", + }, + }, + ) + tool_state = cast( + RuntimeGraphState, + {**state, "lifecycle": wait_update["lifecycle"]}, + ) + + tool_update = await executor.execute( + "tool", + tool_state, + _context(run_id, executor, "command-confirm"), + ) + + assert tools.calls == [(approval_call,)] + assert tool_update["lifecycle"]["pending_tool_calls"] == [] + assert tool_update["lifecycle"]["next_route"] == "compact" + assert [message["role"] for message in tool_update["messages"]] == [ + "tool", + "user", + ] @pytest.mark.asyncio diff --git a/backend/tests/test_agent_runtime_tool_outcome_contract.py b/backend/tests/test_agent_runtime_tool_outcome_contract.py index 03c559625..aa3097302 100644 --- a/backend/tests/test_agent_runtime_tool_outcome_contract.py +++ b/backend/tests/test_agent_runtime_tool_outcome_contract.py @@ -318,6 +318,42 @@ def test_outcome_normalizer_preserves_bounded_email_provider_receipt() -> None: assert "provider_response" not in normalized.metadata +def test_outcome_normalizer_preserves_sanitized_feishu_provider_receipt() -> None: + normalized, archived_body = normalize_tool_outcome( + ToolExecutionOutcome( + status="failed", + result_summary=( + "Feishu rejected approval_create: HTTP 400; code 1390001." + ), + result_ref=None, + error_code="feishu_approval_create_rejected", + metadata={ + "provider_http_status": 400, + "provider_code": 1390001, + "provider_msg": "param is invalid", + "provider_response_body": { + "code": 1390001, + "msg": "param is invalid", + "authorization": "must-not-persist", + }, + }, + ), + effect="external_write", + retry_policy="never", + inline_max_bytes=1024, + ) + + assert archived_body is None + assert normalized.metadata["provider_http_status"] == 400 + assert normalized.metadata["provider_code"] == 1390001 + assert normalized.metadata["provider_msg"] == "param is invalid" + assert normalized.metadata["provider_response_body"] == { + "code": 1390001, + "msg": "param is invalid", + "authorization": "[REDACTED]", + } + + def test_outcome_normalizer_preserves_bounded_okr_transaction_receipt() -> None: normalized, archived_body = normalize_tool_outcome( ToolExecutionOutcome( diff --git a/backend/tests/test_agent_runtime_tool_step_service.py b/backend/tests/test_agent_runtime_tool_step_service.py index a8416377d..09f6a90a4 100644 --- a/backend/tests/test_agent_runtime_tool_step_service.py +++ b/backend/tests/test_agent_runtime_tool_step_service.py @@ -304,6 +304,29 @@ def _at_call(call_id: str, participant_ids: list[str]) -> dict: } +def _approval_create_call( + call_id: str = "call-approval-create", + *, + amount: str = "128.50", +) -> dict: + target_member_id = "11111111-1111-1111-1111-111111111111" + return { + "id": call_id, + "type": "function", + "function": { + "name": "feishu_approval_create", + "arguments": ( + "{" + '"approval_code":"expense-approval",' + f'"target_member_id":"{target_member_id}",' + '"form_data":"[{\\"id\\":\\"amount\\",' + f'\\"type\\":\\"amount\\",\\"value\\":\\"{amount}\\"}}]"' + "}" + ), + }, + } + + async def _unexpected_executor(*args, **kwargs): raise AssertionError(f"at must not reach the application tool executor: {args}, {kwargs}") @@ -385,6 +408,462 @@ async def test_invalid_group_at_arguments_return_failed_tool_result_for_repair() assert result.messages[0]["error_code"] == "group_at_arguments_invalid" +@pytest.mark.asyncio +async def test_feishu_approval_create_waits_for_chat_confirmation_before_receipt( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _approval_create_call() + state = _state(tenant_id, agent, (call,)) + + async def tools(agent_id): + assert agent_id == agent.id + return [ + { + "type": "function", + "function": {"name": "feishu_approval_create"}, + } + ] + + async def reserve(db, **kwargs): + raise AssertionError( + f"Unconfirmed approval created a tool receipt: {db}, {kwargs}" + ) + + async def forbidden_executor(*args, **kwargs): + raise AssertionError( + f"Unconfirmed approval reached Feishu: {args}, {kwargs}" + ) + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None), + tool_provider=tools, + tool_executor=forbidden_executor, + ) + + result = await service.execute_pending(state, _context(state), (call,)) + + assert result.error is None + assert result.messages == () + assert result.pending_tool_calls == (call,) + assert result.waiting_request is not None + assert result.waiting_request["waiting_type"] == "user" + assert result.waiting_request["reason"] == ( + "feishu_approval_create_confirmation" + ) + assert result.waiting_request["tool_call_id"] == "call-approval-create" + assert result.waiting_request["correlation_id"] + assert "审批定义标识" in str(result.waiting_request["question"]) + assert "表单字段 1 项" in str(result.waiting_request["question"]) + assert "128.50" not in str(result.waiting_request["question"]) + assert result.waiting_request["confirmation_phrase"] in str( + result.waiting_request["question"] + ) + + +@pytest.mark.asyncio +async def test_feishu_approval_create_executes_exact_call_after_chat_confirmation( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _approval_create_call() + state = _state(tenant_id, agent, (call,)) + context = _context(state) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "call-approval-create", + "feishu_approval_create", + ) + reservation_calls: list[dict] = [] + execution_calls: list[dict] = [] + + async def tools(agent_id): + assert agent_id == agent.id + return [ + { + "type": "function", + "function": {"name": "feishu_approval_create"}, + } + ] + + async def reserve(db, **kwargs): + del db + reservation_calls.append(kwargs) + return _reservation(execution) + + async def mark_succeeded(db, **kwargs): + del db + execution.status = "succeeded" + execution.result_summary = kwargs["result_summary"] + execution.result_ref = kwargs["result_ref"] + execution.result_metadata = kwargs["metadata"] + return execution + + async def executor( + name, + arguments, + agent_id, + user_id, + session_id="", + on_output=None, + *, + runtime_authorization=None, + runtime_run_id=None, + runtime_tool_call_id=None, + runtime_execution_id=None, + runtime_lease_owner=None, + runtime_tenant_id=None, + ): + execution_calls.append( + { + "name": name, + "arguments": arguments, + "agent_id": agent_id, + "user_id": user_id, + "session_id": session_id, + "on_output": on_output, + "runtime_authorization": runtime_authorization, + "runtime_run_id": runtime_run_id, + "runtime_tool_call_id": runtime_tool_call_id, + "runtime_execution_id": runtime_execution_id, + "runtime_lease_owner": runtime_lease_owner, + "runtime_tenant_id": runtime_tenant_id, + } + ) + return ToolExecutionOutcome( + status="succeeded", + result_summary='{"instance_code":"approval-1"}', + result_ref="approval-1", + ) + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr( + tool_step_service, + "mark_tool_execution_succeeded", + mark_succeeded, + ) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None, None), + tool_provider=tools, + tool_executor=executor, + ) + + waiting = await service.execute_pending(state, context, (call,)) + assert waiting.waiting_request is not None + state["lifecycle"]["resumed_waiting_request"] = dict( + waiting.waiting_request + ) + state["lifecycle"]["deferred_resume_messages"] = [ + { + "id": "confirmation-message", + "role": "user", + "content": waiting.waiting_request["confirmation_phrase"], + "runtime_confirmation_text": waiting.waiting_request[ + "confirmation_phrase" + ], + "runtime_input": "resume", + } + ] + + resumed = await service.execute_pending(state, context, (call,)) + + assert resumed.error is None + assert resumed.waiting_request is None + assert resumed.pending_tool_calls == () + assert resumed.messages[0]["execution_status"] == "succeeded" + assert len(reservation_calls) == 1 + assert len(execution_calls) == 1 + assert execution_calls[0]["name"] == "feishu_approval_create" + assert isinstance( + execution_calls[0]["runtime_authorization"], + tool_step_service.FeishuApprovalCreateAuthorization, + ) + assert execution_calls[0]["runtime_run_id"] == context.run_id + assert execution_calls[0]["runtime_tool_call_id"] == ( + "call-approval-create" + ) + assert execution_calls[0]["runtime_execution_id"] == str(execution.id) + assert execution_calls[0]["runtime_lease_owner"] + assert execution_calls[0]["runtime_tenant_id"] == context.tenant_id + assert execution_calls[0]["arguments"] == { + "approval_code": "expense-approval", + "target_member_id": "11111111-1111-1111-1111-111111111111", + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("reply", "expected_error"), + [ + ("取消", "tool_confirmation_rejected"), + ("确认发起", "tool_confirmation_not_granted"), + ("确认发起 BAD999", "tool_confirmation_not_granted"), + ("金额改成 100 元", "tool_confirmation_not_granted"), + ("__synonym__", "tool_confirmation_not_granted"), + ("__lower_nonce__", "tool_confirmation_not_granted"), + ("__punctuation__", "tool_confirmation_not_granted"), + ("__altered_spacing__", "tool_confirmation_not_granted"), + ], +) +async def test_feishu_approval_create_never_dispatches_without_affirmative_reply( + monkeypatch, + reply: str, + expected_error: str, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _approval_create_call() + state = _state(tenant_id, agent, (call,)) + context = _context(state) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "call-approval-create", + "feishu_approval_create", + ) + + async def tools(_agent_id): + return [ + { + "type": "function", + "function": {"name": "feishu_approval_create"}, + } + ] + + async def reserve(db, **kwargs): + del db, kwargs + return _reservation(execution) + + async def mark_failed(db, **kwargs): + del db + execution.status = "failed" + execution.result_summary = kwargs["result_summary"] + execution.error_code = kwargs["error_code"] + execution.result_metadata = kwargs["metadata"] + return execution + + async def forbidden_executor(*args, **kwargs): + raise AssertionError( + f"Non-affirmative reply reached Feishu: {args}, {kwargs}" + ) + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr( + tool_step_service, + "mark_tool_execution_failed", + mark_failed, + ) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None, None), + tool_provider=tools, + tool_executor=forbidden_executor, + ) + if reply == "__lower_nonce__": + monkeypatch.setattr( + tool_step_service, + "_feishu_approval_confirmation_correlation", + lambda **_kwargs: ( + "ABCDEF00-0000-0000-0000-000000000000", + "test-arguments-hash", + ), + ) + + waiting = await service.execute_pending(state, context, (call,)) + assert waiting.waiting_request is not None + confirmation_phrase = str(waiting.waiting_request["confirmation_phrase"]) + if reply == "__synonym__": + reply = confirmation_phrase.replace("确认发起", "同意") + elif reply == "__lower_nonce__": + reply = confirmation_phrase.lower() + elif reply == "__punctuation__": + reply = f"{confirmation_phrase}。" + elif reply == "__altered_spacing__": + reply = confirmation_phrase.replace(" ", " ") + state["lifecycle"]["resumed_waiting_request"] = dict( + waiting.waiting_request + ) + state["lifecycle"]["deferred_resume_messages"] = [ + { + "id": "confirmation-message", + "role": "user", + "content": reply, + "runtime_confirmation_text": reply, + "runtime_input": "resume", + } + ] + + resumed = await service.execute_pending(state, context, (call,)) + + assert resumed.error is None + assert resumed.waiting_request is None + assert resumed.pending_tool_calls == () + assert resumed.messages[0]["execution_status"] == "failed" + assert resumed.messages[0]["error_code"] == expected_error + + +def test_feishu_approval_confirmation_rejects_different_actor() -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _approval_create_call() + state = _state(tenant_id, agent, (call,)) + initial_context = _context(state) + call_id, tool_name, arguments = tool_step_service._call_fields(call) + + outcome, waiting_request, confirmation_granted = ( + tool_step_service._feishu_approval_confirmation_gate( + state=state, + context=initial_context, + call_id=call_id, + tool_name=tool_name, + arguments=arguments, + ) + ) + assert outcome is None + assert waiting_request is not None + assert confirmation_granted is False + state["lifecycle"]["resumed_waiting_request"] = dict(waiting_request) + state["lifecycle"]["deferred_resume_messages"] = [ + { + "id": "confirmation-message", + "role": "user", + "content": waiting_request["confirmation_phrase"], + "runtime_confirmation_text": waiting_request[ + "confirmation_phrase" + ], + "runtime_input": "resume", + } + ] + + different_actor_context = _context(state) + assert different_actor_context.actor_user_id != initial_context.actor_user_id + outcome, waiting_request, confirmation_granted = ( + tool_step_service._feishu_approval_confirmation_gate( + state=state, + context=different_actor_context, + call_id=call_id, + tool_name=tool_name, + arguments=arguments, + ) + ) + + assert waiting_request is None + assert confirmation_granted is False + assert outcome is not None + assert outcome.error_code == "tool_confirmation_mismatch" + + +@pytest.mark.asyncio +async def test_feishu_approval_confirmation_rejects_changed_pending_arguments( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + original = _approval_create_call() + state = _state(tenant_id, agent, (original,)) + context = _context(state) + changed = _approval_create_call(amount="999.00") + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "call-approval-create", + "feishu_approval_create", + ) + + async def tools(_agent_id): + return [ + { + "type": "function", + "function": {"name": "feishu_approval_create"}, + } + ] + + async def reserve(db, **kwargs): + del db, kwargs + return _reservation(execution) + + async def mark_failed(db, **kwargs): + del db + execution.status = "failed" + execution.result_summary = kwargs["result_summary"] + execution.error_code = kwargs["error_code"] + execution.result_metadata = kwargs["metadata"] + return execution + + async def forbidden_executor(*args, **kwargs): + raise AssertionError( + f"Changed approval arguments reached Feishu: {args}, {kwargs}" + ) + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr( + tool_step_service, + "mark_tool_execution_failed", + mark_failed, + ) + service = tool_step_service.RuntimeToolStepService( + session_factory=_session_factory(agent), + cancel_source=_CancelSource(None, None), + tool_provider=tools, + tool_executor=forbidden_executor, + ) + + waiting = await service.execute_pending(state, context, (original,)) + assert waiting.waiting_request is not None + state["lifecycle"]["resumed_waiting_request"] = dict( + waiting.waiting_request + ) + state["lifecycle"]["deferred_resume_messages"] = [ + { + "id": "confirmation-message", + "role": "user", + "content": waiting.waiting_request["confirmation_phrase"], + "runtime_confirmation_text": waiting.waiting_request[ + "confirmation_phrase" + ], + "runtime_input": "resume", + } + ] + + resumed = await service.execute_pending(state, context, (changed,)) + + assert resumed.messages[0]["execution_status"] == "failed" + assert resumed.messages[0]["error_code"] == "tool_confirmation_mismatch" + + +def test_feishu_approval_confirmation_is_unavailable_outside_chat() -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _approval_create_call() + state = _state(tenant_id, agent, (call,), source_type="task") + call_id, tool_name, arguments = tool_step_service._call_fields(call) + + outcome, waiting_request, confirmation_granted = ( + tool_step_service._feishu_approval_confirmation_gate( + state=state, + context=_context(state), + call_id=call_id, + tool_name=tool_name, + arguments=arguments, + ) + ) + + assert waiting_request is None + assert confirmation_granted is False + assert outcome is not None + assert outcome.status == "failed" + assert outcome.error_code == "tool_confirmation_unavailable" + + @pytest.mark.asyncio async def test_private_run_rejects_group_at() -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_agent_tools_typed_feishu_approval.py b/backend/tests/test_agent_tools_typed_feishu_approval.py new file mode 100644 index 000000000..7ee067087 --- /dev/null +++ b/backend/tests/test_agent_tools_typed_feishu_approval.py @@ -0,0 +1,389 @@ +"""Focused contracts for Feishu approval definition reads and file uploads.""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import uuid + +import httpx +import pytest + +from app.services import activity_logger, agent_tools +from app.services.agent_runtime.tool_execution import ToolExecutionOutcome +from app.services.builtin_tool_definitions import ( + builtin_model_definition, + builtin_policy, + builtin_readiness, +) +from app.services.feishu_service import feishu_service + + +DEFINITION_GET = "feishu_approval_definition_get" +FILE_UPLOAD = "feishu_approval_file_upload" + + +@pytest.fixture(autouse=True) +def isolate_activity_log(monkeypatch) -> None: + async def no_activity(*args, **kwargs): + del args, kwargs + + monkeypatch.setattr(activity_logger, "log_activity", no_activity) + + +class FakeResponse: + def __init__(self, payload: object, *, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.text = str(payload) + + def json(self): + if isinstance(self._payload, BaseException): + raise self._payload + return self._payload + + +class FakeHTTP: + def __init__(self) -> None: + self.responses: dict[str, list[object]] = defaultdict(list) + self.calls: list[tuple[str, str, dict]] = [] + + def add(self, method: str, *responses: object) -> None: + self.responses[method].extend(responses) + + async def request(self, method: str, url: str, **kwargs): + self.calls.append((method, url, kwargs)) + if not self.responses[method]: + raise AssertionError(f"unexpected {method.upper()} request: {url}") + response = self.responses[method].pop(0) + if isinstance(response, BaseException): + raise response + return response + + +def install_feishu_provider(monkeypatch, transport: FakeHTTP) -> None: + class Client: + def __init__(self, *args, **kwargs): + del args, kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def get(self, url, **kwargs): + return await transport.request("get", url, **kwargs) + + async def post(self, url, **kwargs): + return await transport.request("post", url, **kwargs) + + async def credentials(_agent_id): + return "app-id", "app-secret" + + async def tenant_token(_app_id, _app_secret): + return "tenant-token" + + monkeypatch.setattr(httpx, "AsyncClient", Client) + monkeypatch.setattr(agent_tools, "_get_feishu_credentials", credentials) + monkeypatch.setattr(feishu_service, "get_tenant_access_token", tenant_token) + + +def assert_outcome(value: object, status: str) -> ToolExecutionOutcome: + assert isinstance(value, ToolExecutionOutcome) + assert value.status == status + return value + + +def schema_for(tool_name: str) -> dict: + return builtin_model_definition(tool_name)["function"]["parameters"] + + +async def definition_get(arguments: dict) -> ToolExecutionOutcome: + return await agent_tools.execute_builtin_tool_outcome( + DEFINITION_GET, + arguments, + agent_id=uuid.uuid4(), + user_id=uuid.uuid4(), + ) + + +async def file_upload( + workspace_root: Path, + arguments: dict, +) -> ToolExecutionOutcome: + return await agent_tools._feishu_approval_file_upload_outcome( + uuid.uuid4(), + workspace_root, + arguments, + ) + + +def test_approval_definition_get_schema_selects_one_bounded_section() -> None: + schema = schema_for(DEFINITION_GET) + + assert schema["additionalProperties"] is False + assert schema["required"] == ["approval_code"] + assert set(schema["properties"]) == { + "approval_code", + "section", + "offset", + "limit", + } + assert schema["properties"]["section"]["enum"] == [ + "summary", + "form", + "nodes", + ] + assert schema["properties"]["limit"]["maximum"] == 50 + assert builtin_policy(DEFINITION_GET) == { + "effect": "read", + "retry_policy": "safe", + "parallel_safe": True, + } + assert builtin_readiness(DEFINITION_GET) == "feishu_channel" + + +def test_approval_file_upload_schema_requires_workspace_file_type() -> None: + schema = schema_for(FILE_UPLOAD) + + assert schema["additionalProperties"] is False + assert schema["required"] == ["file_path", "file_type"] + assert set(schema["properties"]) == {"file_path", "file_type"} + assert schema["properties"]["file_type"]["enum"] == [ + "image", + "attachment", + ] + assert builtin_policy(FILE_UPLOAD) == { + "effect": "external_write", + "retry_policy": "never", + "parallel_safe": False, + } + assert builtin_readiness(FILE_UPLOAD) == "feishu_channel" + + +@pytest.mark.asyncio +async def test_legacy_execute_tool_fails_closed_for_approval_create() -> None: + result = await agent_tools.execute_tool( + "feishu_approval_create", + { + "approval_code": "expense", + "target_member_id": str(uuid.uuid4()), + "form_data": "[]", + }, + uuid.uuid4(), + uuid.uuid4(), + ) + + assert result == ( + "Feishu approval creation is blocked outside Durable Runtime " + "conversation confirmation." + ) + + +@pytest.mark.asyncio +async def test_approval_definition_get_returns_requested_form_window( + monkeypatch, +) -> None: + transport = FakeHTTP() + transport.add( + "get", + FakeResponse( + { + "code": 0, + "data": { + "approval_name": "Expense", + "form": ( + '[{"id":"amount","type":"amount"},' + '{"id":"reason","type":"textarea"}]' + ), + "node_list": [{"id": "start"}], + }, + } + ), + ) + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await definition_get( + { + "approval_code": "expense/custom", + "section": "form", + "offset": 1, + "limit": 1, + } + ), + "succeeded", + ) + + assert '"id":"reason"' in (outcome.summary or "") + assert '"id":"amount"' not in (outcome.summary or "") + assert outcome.metadata == { + "section": "form", + "offset": 1, + "returned_count": 1, + "has_more": False, + "next_offset": None, + } + assert transport.calls[0][1].endswith("/expense%2Fcustom") + + +@pytest.mark.asyncio +async def test_approval_definition_get_business_rejection_is_nonretryable( + monkeypatch, +) -> None: + transport = FakeHTTP() + transport.add( + "get", + FakeResponse({"code": 99991663, "msg": "permission denied"}), + ) + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await definition_get({"approval_code": "expense"}), + "failed", + ) + + assert outcome.retryable is False + assert outcome.error_code == "feishu_approval_definition_get_rejected" + + +@pytest.mark.asyncio +async def test_approval_file_upload_returns_provider_file_code_once( + monkeypatch, + tmp_path, +) -> None: + receipt = tmp_path / "receipt.pdf" + receipt.write_bytes(b"receipt-bytes") + transport = FakeHTTP() + transport.add( + "post", + FakeResponse({"code": 0, "data": {"code": "file-code-1"}}), + ) + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await file_upload( + tmp_path, + {"file_path": "receipt.pdf", "file_type": "attachment"}, + ), + "succeeded", + ) + + assert outcome.result_ref == "file-code-1" + assert outcome.metadata == { + "file_name": "receipt.pdf", + "file_type": "attachment", + "size_bytes": len(b"receipt-bytes"), + } + assert len(transport.calls) == 1 + _, url, kwargs = transport.calls[0] + assert url.endswith("/approval/openapi/v2/file/upload") + assert kwargs["data"] == {"name": "receipt.pdf", "type": "attachment"} + assert kwargs["files"]["content"][:2] == ( + "receipt.pdf", + b"receipt-bytes", + ) + + +@pytest.mark.asyncio +async def test_approval_file_upload_timeout_is_unknown_without_replay( + monkeypatch, + tmp_path, +) -> None: + receipt = tmp_path / "receipt.pdf" + receipt.write_bytes(b"receipt-bytes") + transport = FakeHTTP() + transport.add("post", httpx.ReadTimeout("receipt timed out")) + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await file_upload( + tmp_path, + {"file_path": "receipt.pdf", "file_type": "attachment"}, + ), + "unknown", + ) + + assert outcome.retryable is False + assert outcome.error_code == "feishu_approval_file_upload_outcome_unknown" + assert len(transport.calls) == 1 + + +@pytest.mark.asyncio +async def test_approval_file_upload_business_rejection_is_failed_without_replay( + monkeypatch, + tmp_path, +) -> None: + receipt = tmp_path / "receipt.pdf" + receipt.write_bytes(b"receipt-bytes") + transport = FakeHTTP() + transport.add( + "post", + FakeResponse({"code": 1390001, "msg": "file rejected"}), + ) + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await file_upload( + tmp_path, + {"file_path": "receipt.pdf", "file_type": "attachment"}, + ), + "failed", + ) + + assert outcome.retryable is False + assert outcome.error_code == "feishu_approval_file_upload_rejected" + assert outcome.metadata["provider_http_status"] == 200 + assert outcome.metadata["provider_code"] == 1390001 + assert outcome.metadata["provider_msg"] == "file rejected" + assert outcome.metadata["provider_response_body"] == { + "code": 1390001, + "msg": "file rejected", + } + assert "1390001" in (outcome.summary or "") + assert "file rejected" in (outcome.summary or "") + assert len(transport.calls) == 1 + + +@pytest.mark.asyncio +async def test_approval_file_upload_rejects_workspace_traversal_before_dispatch( + monkeypatch, + tmp_path, +) -> None: + transport = FakeHTTP() + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await file_upload( + tmp_path, + {"file_path": "../receipt.pdf", "file_type": "attachment"}, + ), + "failed", + ) + + assert outcome.error_code == "feishu_approval_file_path_rejected" + assert transport.calls == [] + + +@pytest.mark.asyncio +async def test_approval_file_upload_rejects_oversized_image_before_dispatch( + monkeypatch, + tmp_path, +) -> None: + image = tmp_path / "receipt.png" + with image.open("wb") as stream: + stream.truncate(agent_tools.FEISHU_APPROVAL_IMAGE_MAX_BYTES + 1) + transport = FakeHTTP() + install_feishu_provider(monkeypatch, transport) + + outcome = assert_outcome( + await file_upload( + tmp_path, + {"file_path": "receipt.png", "file_type": "image"}, + ), + "failed", + ) + + assert outcome.error_code == "feishu_approval_file_size_rejected" + assert transport.calls == [] diff --git a/backend/tests/test_agent_tools_typed_feishu_remaining.py b/backend/tests/test_agent_tools_typed_feishu_remaining.py index ae4948d46..52d44d67e 100644 --- a/backend/tests/test_agent_tools_typed_feishu_remaining.py +++ b/backend/tests/test_agent_tools_typed_feishu_remaining.py @@ -11,6 +11,10 @@ import pytest from app.services import activity_logger, agent_tools +from app.services.agent_runtime.feishu_approval_authorization import ( + feishu_approval_create_arguments_hash, + issue_feishu_approval_create_authorization, +) from app.services.agent_runtime.tool_execution import ToolExecutionOutcome from app.services.builtin_tool_definitions import ( builtin_model_definition, @@ -153,10 +157,12 @@ def install_create_target( captured: dict[str, list] = { "resolver": [], "directory": [], + "authorization": [], } target = SimpleNamespace( member=SimpleNamespace( id=target_member_id, + user_id=target_member_id, external_id=provider_user_id, open_id="ou-should-not-be-used", ), @@ -187,7 +193,18 @@ async def query_directory(agent_id, arguments): ], } + async def consume_authorization(authorization, **kwargs): + captured["authorization"].append( + (authorization, dict(kwargs)) + ) + return None + monkeypatch.setattr(agent_tools, "async_session", lambda: FakeDBContext()) + monkeypatch.setattr( + agent_tools, + "_consume_feishu_approval_create_authorization", + consume_authorization, + ) monkeypatch.setattr(agent_tools, "_resolve_roster_human_target", resolve) monkeypatch.setattr(agent_tools, "_query_directory_payload", query_directory) return captured @@ -207,17 +224,45 @@ async def execute( ) -async def execute_hidden_create( +async def execute_approval_create( arguments: dict, *, agent_id: uuid.UUID | None = None, + actor_user_id: uuid.UUID | None = None, ) -> ToolExecutionOutcome: - adapter = getattr(agent_tools, "_feishu_approval_create_outcome", None) - assert callable(adapter), ( - "feishu_approval_create needs a typed adapter before its confirmation " - "gate can expose it" + resolved_agent_id = agent_id or uuid.uuid4() + resolved_actor_user_id = actor_user_id or uuid.UUID( + arguments["target_member_id"] + ) + run_id = str(uuid.uuid4()) + tool_call_id = "call-approval-create" + execution_id = str(uuid.uuid4()) + lease_owner = f"runtime:test:{tool_call_id}" + tenant_id = str(uuid.uuid4()) + authorization = issue_feishu_approval_create_authorization( + run_id=run_id, + tool_call_id="call-approval-create", + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=str(resolved_agent_id), + actor_user_id=str(resolved_actor_user_id), + arguments=arguments, ) - return await adapter(agent_id or uuid.uuid4(), arguments) + outcome = await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + arguments, + agent_id=resolved_agent_id, + user_id=resolved_actor_user_id, + runtime_authorization=authorization, + runtime_run_id=run_id, + runtime_tool_call_id=tool_call_id, + runtime_execution_id=execution_id, + runtime_lease_owner=lease_owner, + runtime_tenant_id=tenant_id, + ) + assert isinstance(outcome, ToolExecutionOutcome) + return outcome def assert_outcome(value: object, status: str) -> ToolExecutionOutcome: @@ -353,7 +398,7 @@ async def no_dynamic(_agent_id): @pytest.mark.asyncio -async def test_approval_create_stays_hidden_until_confirmation_gate_is_wired( +async def test_approval_create_is_visible_when_assigned_and_feishu_is_ready( monkeypatch, ) -> None: assigned = [builtin_model_definition(APPROVAL_CREATE)] @@ -375,8 +420,9 @@ async def no_dynamic(_agent_id): no_dynamic, ) - assert APPROVAL_CREATE not in agent_tools.RUNTIME_TYPED_APPLICATION_TOOL_NAMES - assert await agent_tools.get_runtime_agent_tools_for_llm(uuid.uuid4()) == [] + assert APPROVAL_CREATE in agent_tools.RUNTIME_TYPED_APPLICATION_TOOL_NAMES + resolved = await agent_tools.get_runtime_agent_tools_for_llm(uuid.uuid4()) + assert [tool["function"]["name"] for tool in resolved] == [APPROVAL_CREATE] def test_user_search_schema_uses_directory_query_and_bounded_pagination() -> None: @@ -450,6 +496,8 @@ def test_approval_create_schema_uses_stable_member_id_and_sensitive_form() -> No "approval_code", "target_member_id", "form_data", + "department_id", + "uuid", } assert "user_id" not in schema["properties"] assert builtin_policy(APPROVAL_CREATE) == { @@ -474,6 +522,206 @@ def test_approval_create_form_data_is_redacted_from_observability() -> None: assert sanitized["form_data"] == "[REDACTED]" +def test_approval_create_rejects_attachment_objects_before_confirmation() -> None: + validated, error = agent_tools.validate_feishu_approval_create_arguments( + { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": json.dumps( + [ + { + "id": "receipt", + "type": "attachmentV2", + "value": [{"file_code": "file-code-1"}], + } + ] + ), + } + ) + + assert validated is None + assert error is not None + assert error.error_code == "invalid_tool_arguments" + assert "string file codes" in (error.summary or "") + + +def test_approval_create_accepts_attachment_file_code_strings() -> None: + validated, error = agent_tools.validate_feishu_approval_create_arguments( + { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": json.dumps( + [ + { + "id": "receipt", + "type": "attachmentV2", + "value": ["file-code-1"], + } + ] + ), + } + ) + + assert error is None + assert validated is not None + + +@pytest.mark.asyncio +async def test_approval_create_typed_dispatch_fails_without_runtime_proof() -> None: + outcome = assert_outcome( + await execute( + APPROVAL_CREATE, + { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": "[]", + }, + ), + "failed", + ) + + assert outcome.error_code == "tool_confirmation_required" + + +@pytest.mark.asyncio +async def test_approval_create_runtime_proof_rejects_changed_arguments() -> None: + agent_id = uuid.uuid4() + actor_user_id = uuid.uuid4() + original_arguments = { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + } + run_id = str(uuid.uuid4()) + tool_call_id = "call-approval-create" + execution_id = str(uuid.uuid4()) + lease_owner = f"runtime:test:{tool_call_id}" + tenant_id = str(uuid.uuid4()) + authorization = issue_feishu_approval_create_authorization( + run_id=run_id, + tool_call_id="call-approval-create", + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=str(agent_id), + actor_user_id=str(actor_user_id), + arguments=original_arguments, + ) + changed_arguments = { + **original_arguments, + "form_data": ( + '[{"id":"amount","type":"amount","value":"999.00"}]' + ), + } + + outcome = assert_outcome( + await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + changed_arguments, + agent_id=agent_id, + user_id=actor_user_id, + runtime_authorization=authorization, + runtime_run_id=run_id, + runtime_tool_call_id=tool_call_id, + runtime_execution_id=execution_id, + runtime_lease_owner=lease_owner, + runtime_tenant_id=tenant_id, + ), + "failed", + ) + + assert outcome.error_code == "tool_confirmation_required" + + +@pytest.mark.asyncio +async def test_approval_create_runtime_proof_rejects_different_call() -> None: + agent_id = uuid.uuid4() + actor_user_id = uuid.uuid4() + arguments = { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": "[]", + } + run_id = str(uuid.uuid4()) + execution_id = str(uuid.uuid4()) + lease_owner = "runtime:test:call-a" + tenant_id = str(uuid.uuid4()) + authorization = issue_feishu_approval_create_authorization( + run_id=run_id, + tool_call_id="call-a", + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=tenant_id, + agent_id=str(agent_id), + actor_user_id=str(actor_user_id), + arguments=arguments, + ) + + outcome = assert_outcome( + await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + arguments, + agent_id=agent_id, + user_id=actor_user_id, + runtime_authorization=authorization, + runtime_run_id=run_id, + runtime_tool_call_id="call-b", + runtime_execution_id=execution_id, + runtime_lease_owner=lease_owner, + runtime_tenant_id=tenant_id, + ), + "failed", + ) + + assert outcome.error_code == "tool_confirmation_required" + + +@pytest.mark.asyncio +async def test_approval_create_runtime_proof_rejects_cross_tenant() -> None: + agent_id = uuid.uuid4() + actor_user_id = uuid.uuid4() + arguments = { + "approval_code": "approval-definition-1", + "target_member_id": str(uuid.uuid4()), + "form_data": "[]", + } + run_id = str(uuid.uuid4()) + execution_id = str(uuid.uuid4()) + lease_owner = "runtime:test:call-approval-create" + proof_tenant_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + runtime_tenant_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + authorization = issue_feishu_approval_create_authorization( + run_id=run_id, + tool_call_id="call-approval-create", + execution_id=execution_id, + lease_owner=lease_owner, + tenant_id=proof_tenant_id, + agent_id=str(agent_id), + actor_user_id=str(actor_user_id), + arguments=arguments, + ) + + outcome = assert_outcome( + await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + arguments, + agent_id=agent_id, + user_id=actor_user_id, + runtime_authorization=authorization, + runtime_run_id=run_id, + runtime_tool_call_id="call-approval-create", + runtime_execution_id=execution_id, + runtime_lease_owner=lease_owner, + runtime_tenant_id=runtime_tenant_id, + ), + "failed", + ) + + assert outcome.error_code == "tool_confirmation_required" + + @pytest.mark.asyncio async def test_user_search_reuses_tenant_scoped_human_directory_window( monkeypatch, @@ -896,6 +1144,15 @@ async def test_approval_reads_classify_business_rejection_as_nonretryable( assert outcome.retryable is False assert outcome.error_code + assert outcome.metadata["provider_http_status"] == 200 + assert outcome.metadata["provider_code"] == 99991663 + assert outcome.metadata["provider_msg"] == "permission denied" + assert outcome.metadata["provider_response_body"] == { + "code": 99991663, + "msg": "permission denied", + } + assert "99991663" in (outcome.summary or "") + assert "permission denied" in (outcome.summary or "") @pytest.mark.parametrize("tool_name", sorted(F4_READ_TOOLS - {"feishu_user_search"})) @@ -922,6 +1179,13 @@ async def test_approval_reads_classify_http_4xx_as_nonretryable( assert outcome.retryable is False assert outcome.error_code + assert outcome.metadata["provider_http_status"] == 400 + assert outcome.metadata["provider_response_body"] == { + "code": 0, + "msg": "bad request", + } + assert "HTTP 400" in (outcome.summary or "") + assert "bad request" in (outcome.summary or "") @pytest.mark.parametrize("tool_name", sorted(F4_READ_TOOLS - {"feishu_user_search"})) @@ -1002,7 +1266,10 @@ async def test_approval_create_resolves_stable_member_and_returns_receipt_once( ) -> None: target_member_id = uuid.uuid4() agent_id = uuid.uuid4() - form_data = '[{"id":"reason","value":"FORM-PRIVATE-VALUE"}]' + form_data = ( + '[{"id":"reason","type":"textarea",' + '"value":"FORM-PRIVATE-VALUE"}]' + ) transport = FakeHTTP() transport.add( "post", @@ -1020,7 +1287,7 @@ async def test_approval_create_resolves_stable_member_and_returns_receipt_once( ) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1046,9 +1313,268 @@ async def test_approval_create_resolves_stable_member_and_returns_receipt_once( assert resolved_agent_id == agent_id assert resolver_args["target_member_id"] == str(target_member_id) assert resolver_args["provider_type"] == "feishu" + assert resolver_args["require_platform_user"] is True assert resolver_args["require_provider_identity"] is True +@pytest.mark.asyncio +async def test_approval_create_consumes_receipt_proof_before_provider_replay( + monkeypatch, +) -> None: + agent_id = uuid.uuid4() + actor_user_id = uuid.uuid4() + target_member_id = uuid.uuid4() + run_id = uuid.uuid4() + execution_id = uuid.uuid4() + tenant_id = uuid.uuid4() + tool_call_id = "call-approval-create" + lease_owner = f"runtime:test:{tool_call_id}" + arguments = { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + } + execution = agent_tools.AgentToolExecution( + id=execution_id, + tenant_id=tenant_id, + run_id=run_id, + tool_call_id=tool_call_id, + tool_name=APPROVAL_CREATE, + assistant_message_id="assistant-message-1", + arguments_hash=feishu_approval_create_arguments_hash(arguments), + sanitized_arguments={"form_data": "[REDACTED]"}, + effect="external_write", + retry_policy="never", + result_metadata={}, + status="started", + lease_owner=lease_owner, + ) + + class Result: + def scalar_one_or_none(self): + return execution + + class Transaction: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + class LedgerDB: + def begin(self): + return Transaction() + + async def execute(self, _statement): + return Result() + + class LedgerDBContext: + async def __aenter__(self): + return LedgerDB() + + async def __aexit__(self, *_args): + return False + + target = SimpleNamespace( + member=SimpleNamespace( + id=target_member_id, + user_id=actor_user_id, + external_id="user-applicant", + open_id="ou-applicant", + ), + provider=SimpleNamespace(provider_type="feishu"), + provider_type="feishu", + ) + + async def resolve_target(_db, _agent_id, **_kwargs): + return target, None + + transport = FakeHTTP() + transport.add( + "post", + FakeResponse( + { + "code": 0, + "data": {"instance_code": "approval-instance-once"}, + } + ), + ) + install_feishu_provider(monkeypatch, transport) + monkeypatch.setattr(agent_tools, "async_session", lambda: LedgerDBContext()) + monkeypatch.setattr( + agent_tools, + "_resolve_roster_human_target", + resolve_target, + ) + authorization = issue_feishu_approval_create_authorization( + run_id=str(run_id), + tool_call_id=tool_call_id, + execution_id=str(execution_id), + lease_owner=lease_owner, + tenant_id=str(tenant_id), + agent_id=str(agent_id), + actor_user_id=str(actor_user_id), + arguments=arguments, + ) + execution_context = { + "runtime_authorization": authorization, + "runtime_run_id": str(run_id), + "runtime_tool_call_id": tool_call_id, + "runtime_execution_id": str(execution_id), + "runtime_lease_owner": lease_owner, + "runtime_tenant_id": str(tenant_id), + } + + first = assert_outcome( + await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + arguments, + agent_id=agent_id, + user_id=actor_user_id, + **execution_context, + ), + "succeeded", + ) + replay = assert_outcome( + await agent_tools.execute_builtin_tool_outcome( + APPROVAL_CREATE, + arguments, + agent_id=agent_id, + user_id=actor_user_id, + **execution_context, + ), + "failed", + ) + + assert first.result_ref == "approval-instance-once" + assert replay.error_code == "tool_confirmation_required" + assert len(transport.calls_for("post")) == 1 + + +@pytest.mark.asyncio +async def test_approval_create_forwards_safe_optional_provider_fields( + monkeypatch, +) -> None: + target_member_id = uuid.uuid4() + transport = FakeHTTP() + transport.add( + "post", + FakeResponse( + { + "code": 0, + "data": {"instance_code": "approval-instance-2"}, + } + ), + ) + install_feishu_provider(monkeypatch, transport) + install_create_target(monkeypatch, target_member_id=target_member_id) + + assert_outcome( + await execute_approval_create( + { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + "department_id": "department-1", + "uuid": "reimbursement-2026-08-07-1", + } + ), + "succeeded", + ) + + request_body = transport.calls_for("post")[0][2]["json"] + assert request_body["department_id"] == "department-1" + assert request_body["uuid"] == "reimbursement-2026-08-07-1" + + +@pytest.mark.asyncio +async def test_approval_create_rejects_raw_approver_open_ids( + monkeypatch, +) -> None: + target_member_id = uuid.uuid4() + transport = FakeHTTP() + install_feishu_provider(monkeypatch, transport) + install_create_target(monkeypatch, target_member_id=target_member_id) + + outcome = assert_outcome( + await execute_approval_create( + { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + "node_approver_open_id_list": [ + {"key": "approver-node", "value": ["ou-approver"]} + ], + } + ), + "failed", + ) + + assert outcome.error_code == "invalid_tool_arguments" + assert transport.calls_for("post") == [] + + +@pytest.mark.asyncio +async def test_approval_create_rejects_applicant_other_than_confirming_actor( + monkeypatch, +) -> None: + target_member_id = uuid.uuid4() + transport = FakeHTTP() + install_feishu_provider(monkeypatch, transport) + install_create_target(monkeypatch, target_member_id=target_member_id) + + outcome = assert_outcome( + await execute_approval_create( + { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + }, + actor_user_id=uuid.uuid4(), + ), + "failed", + ) + + assert outcome.error_code == "feishu_approval_applicant_mismatch" + assert transport.calls_for("post") == [] + + +@pytest.mark.asyncio +async def test_approval_create_rejects_confirmation_summary_before_dispatch( + monkeypatch, +) -> None: + target_member_id = uuid.uuid4() + transport = FakeHTTP() + install_feishu_provider(monkeypatch, transport) + install_create_target(monkeypatch, target_member_id=target_member_id) + + outcome = assert_outcome( + await execute_approval_create( + { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": ( + '[{"id":"amount","type":"amount","value":"128.50"}]' + ), + "confirmation_summary": "包含不可信模型内容", + } + ), + "failed", + ) + + assert outcome.retryable is False + assert outcome.error_code == "invalid_tool_arguments" + assert transport.calls == [] + + @pytest.mark.asyncio async def test_approval_create_rejects_non_array_form_before_dispatch( monkeypatch, @@ -1059,7 +1585,7 @@ async def test_approval_create_rejects_non_array_form_before_dispatch( install_create_target(monkeypatch, target_member_id=target_member_id) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1089,7 +1615,7 @@ async def test_approval_create_rejects_non_feishu_member_before_dispatch( ) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1115,7 +1641,7 @@ async def test_approval_create_missing_provider_receipt_is_unknown_without_repla install_create_target(monkeypatch, target_member_id=target_member_id) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1141,7 +1667,7 @@ async def test_approval_create_dispatch_timeout_is_unknown_without_replay( install_create_target(monkeypatch, target_member_id=target_member_id) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1170,7 +1696,7 @@ async def test_approval_create_business_rejection_is_failed_without_replay( install_create_target(monkeypatch, target_member_id=target_member_id) outcome = assert_outcome( - await execute_hidden_create( + await execute_approval_create( { "approval_code": "approval-definition-1", "target_member_id": str(target_member_id), @@ -1182,4 +1708,61 @@ async def test_approval_create_business_rejection_is_failed_without_replay( assert outcome.retryable is False assert outcome.error_code + assert outcome.metadata["provider_http_status"] == 200 + assert outcome.metadata["provider_code"] == 1390001 + assert outcome.metadata["provider_msg"] == "approval rejected" + assert outcome.metadata["provider_response_body"] == { + "code": 1390001, + "msg": "approval rejected", + } + assert "1390001" in (outcome.summary or "") + assert "approval rejected" in (outcome.summary or "") + assert len(transport.calls_for("post")) == 1 + + +@pytest.mark.asyncio +async def test_approval_create_http_400_preserves_provider_response( + monkeypatch, +) -> None: + target_member_id = uuid.uuid4() + transport = FakeHTTP() + transport.add( + "post", + FakeResponse( + { + "code": 1390001, + "msg": "param is invalid: control=receipt", + "data": {"control_id": "receipt"}, + }, + status_code=400, + ), + ) + install_feishu_provider(monkeypatch, transport) + install_create_target(monkeypatch, target_member_id=target_member_id) + + outcome = assert_outcome( + await execute_approval_create( + { + "approval_code": "approval-definition-1", + "target_member_id": str(target_member_id), + "form_data": "[]", + } + ), + "failed", + ) + + assert outcome.error_code == "feishu_approval_create_rejected" + assert outcome.metadata == { + "provider_http_status": 400, + "provider_code": 1390001, + "provider_msg": "param is invalid: control=receipt", + "provider_response_body": { + "code": 1390001, + "msg": "param is invalid: control=receipt", + "data": {"control_id": "receipt"}, + }, + } + assert "HTTP 400" in (outcome.summary or "") + assert "1390001" in (outcome.summary or "") + assert "control=receipt" in (outcome.summary or "") assert len(transport.calls_for("post")) == 1