From 3557908d289ba809ead5085ad3ffa313c1892d67 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Fri, 7 Aug 2026 18:07:47 +0800 Subject: [PATCH 1/2] Require receipt-bound chat consent for reimbursement approvals Add typed Feishu approval definition, attachment upload, and create tools while binding provider submission to the exact tenant, Run, Tool Ledger receipt, applicant, and in-chat confirmation. Constraint: Approval creation must not depend on a new frontend or legacy ApprovalRequest flow Constraint: Existing user worktree changes remain outside this commit Rejected: Bare yes/no confirmation | stale replies could authorize another request Rejected: Raw approver and CC open IDs | bypasses tenant roster authorization Rejected: Reusable bearer proof | could replay an identical provider write Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not expose feishu_approval_create outside the receipt-bound Durable Runtime conversation gate Tested: 335 relevant backend tests; scoped Ruff; compileall; diff check; independent code review and verification Not-tested: Live Feishu Provider create and 3010 deployment --- .../app/services/agent_runtime/chat_intake.py | 2 + .../feishu_approval_authorization.py | 154 ++++ .../services/agent_runtime/node_executor.py | 42 +- backend/app/services/agent_runtime/state.py | 1 + .../agent_runtime/tool_step_service.py | 272 +++++++ backend/app/services/agent_tools.py | 762 +++++++++++++++++- .../app/services/builtin_tool_definitions.py | 101 ++- .../tests/test_agent_runtime_chat_intake.py | 12 +- .../tests/test_agent_runtime_node_executor.py | 94 ++- .../test_agent_runtime_tool_step_service.py | 479 +++++++++++ .../test_agent_tools_typed_feishu_approval.py | 380 +++++++++ ...test_agent_tools_typed_feishu_remaining.py | 498 +++++++++++- 12 files changed, 2749 insertions(+), 48 deletions(-) create mode 100644 backend/app/services/agent_runtime/feishu_approval_authorization.py create mode 100644 backend/tests/test_agent_tools_typed_feishu_approval.py 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_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..2a2cf9253 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": @@ -16087,6 +16200,326 @@ 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 = getattr(response, "status_code", None) + if not isinstance(status_code, int) or isinstance(status_code, bool): + return _typed_unknown( + "Feishu approval_file_upload returned no HTTP receipt; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=receipt_metadata, + ) + if status_code == 429 or status_code >= 500: + return _typed_unknown( + "Feishu approval_file_upload may have taken effect; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=receipt_metadata, + ) + if not 200 <= status_code < 300: + return _typed_failure( + "Feishu rejected approval_file_upload.", + "feishu_approval_file_upload_rejected", + metadata=receipt_metadata, + ) + try: + payload = response.json() + except Exception: + return _typed_unknown( + "Feishu approval_file_upload returned an unreadable receipt; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=receipt_metadata, + ) + if not isinstance(payload, Mapping): + return _typed_unknown( + "Feishu approval_file_upload returned an invalid receipt; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=receipt_metadata, + ) + code = payload.get("code") + if isinstance(code, bool) or not isinstance(code, int): + return _typed_unknown( + "Feishu approval_file_upload returned no business receipt; reconcile before retrying.", + "feishu_approval_file_upload_outcome_unknown", + metadata=receipt_metadata, + ) + if code != 0: + return _typed_failure( + "Feishu rejected approval_file_upload.", + "feishu_approval_file_upload_rejected", + metadata=receipt_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 +16898,275 @@ 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", + ) + + 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 +17184,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 +17204,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( @@ -16590,6 +17257,13 @@ async def _feishu_approval_create_outcome( "Feishu approval_create returned no business receipt; reconcile before retrying.", "feishu_approval_create_outcome_unknown", ) + 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, + ) if code != 0: return _typed_failure( "Feishu rejected approval_create.", @@ -16605,19 +17279,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_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..383b746d2 --- /dev/null +++ b/backend/tests/test_agent_tools_typed_feishu_approval.py @@ -0,0 +1,380 @@ +"""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 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..dc84e56b6 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,162 @@ def test_approval_create_form_data_is_redacted_from_observability() -> None: assert sanitized["form_data"] == "[REDACTED]" +@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, @@ -1002,7 +1206,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 +1227,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 +1253,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 +1525,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 +1555,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 +1581,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 +1607,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 +1636,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), From 9313e5c5014e163403bb571d36b52adef5538169 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Tue, 11 Aug 2026 20:59:31 +0800 Subject: [PATCH 2/2] Make Feishu approval failures repairable without replay Preserve a bounded, sanitized Provider receipt in the Tool outcome and surface HTTP status, business code, message, and response body to the model. Approval reads and writes retain their existing retry classification, while attachment inputs are rejected before confirmation unless they contain stable file-code strings. Constraint: External approval writes must never be replayed after an ambiguous Provider outcome Constraint: Provider response metadata must pass through the Tool Ledger sanitizer before model visibility Rejected: Return only a generic rejection string | leaves the model unable to repair invalid form controls or permissions Rejected: Accept attachment objects in form_data | confirmation would bind an unstable provider payload shape Confidence: high Scope-risk: moderate Directive: Preserve provider receipt bounds and redaction whenever adding new Feishu failure fields Tested: 804 Runtime and Tool tests; 201 approval-focused tests; fatal Ruff; Architecture Guard P0; diff check Not-tested: Live Feishu Provider create and deployed 3010 approval flow --- .../services/agent_runtime/tool_execution.py | 4 + .../agent_runtime/tool_result_store.py | 4 + backend/app/services/agent_tools.py | 317 +++++++++++++++--- ...est_agent_runtime_tool_outcome_contract.py | 36 ++ .../test_agent_tools_typed_feishu_approval.py | 9 + ...test_agent_tools_typed_feishu_remaining.py | 117 +++++++ 6 files changed, 442 insertions(+), 45 deletions(-) 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_tools.py b/backend/app/services/agent_tools.py index 2a2cf9253..6187db475 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -16114,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( @@ -16121,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 @@ -16455,51 +16592,85 @@ async def _feishu_approval_file_upload_outcome( metadata=receipt_metadata, ) - 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) + ) + failure_metadata = {**receipt_metadata, **provider_receipt} + if status_code is None: return _typed_unknown( - "Feishu approval_file_upload returned no HTTP receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_file_upload", + "returned no HTTP receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_file_upload_outcome_unknown", - metadata=receipt_metadata, + metadata=failure_metadata, ) if status_code == 429 or status_code >= 500: return _typed_unknown( - "Feishu approval_file_upload may have taken effect; reconcile before retrying.", + _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=receipt_metadata, + metadata=failure_metadata, ) if not 200 <= status_code < 300: return _typed_failure( - "Feishu rejected approval_file_upload.", + _feishu_provider_error_summary( + "approval_file_upload", + "rejected", + provider_receipt, + ), "feishu_approval_file_upload_rejected", - metadata=receipt_metadata, + metadata=failure_metadata, ) - try: - payload = response.json() - except Exception: + if not payload_is_json: return _typed_unknown( - "Feishu approval_file_upload returned an unreadable receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_file_upload", + "returned an unreadable receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_file_upload_outcome_unknown", - metadata=receipt_metadata, + metadata=failure_metadata, ) if not isinstance(payload, Mapping): return _typed_unknown( - "Feishu approval_file_upload returned an invalid receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_file_upload", + "returned an invalid receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_file_upload_outcome_unknown", - metadata=receipt_metadata, + metadata=failure_metadata, ) code = payload.get("code") if isinstance(code, bool) or not isinstance(code, int): return _typed_unknown( - "Feishu approval_file_upload returned no business receipt; reconcile before retrying.", + _feishu_provider_error_summary( + "approval_file_upload", + "returned no business receipt for", + provider_receipt, + ) + + " Reconcile before retrying.", "feishu_approval_file_upload_outcome_unknown", - metadata=receipt_metadata, + metadata=failure_metadata, ) if code != 0: return _typed_failure( - "Feishu rejected approval_file_upload.", + _feishu_provider_error_summary( + "approval_file_upload", + "rejected", + provider_receipt, + ), "feishu_approval_file_upload_rejected", - metadata=receipt_metadata, + metadata=failure_metadata, ) data = payload.get("data") file_code = ( @@ -16969,6 +17140,21 @@ def validate_feishu_approval_create_arguments( "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"): @@ -17223,39 +17409,74 @@ 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: @@ -17263,11 +17484,17 @@ async def _feishu_approval_create_outcome( "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 = ( 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_tools_typed_feishu_approval.py b/backend/tests/test_agent_tools_typed_feishu_approval.py index 383b746d2..7ee067087 100644 --- a/backend/tests/test_agent_tools_typed_feishu_approval.py +++ b/backend/tests/test_agent_tools_typed_feishu_approval.py @@ -334,6 +334,15 @@ async def test_approval_file_upload_business_rejection_is_failed_without_replay( 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 diff --git a/backend/tests/test_agent_tools_typed_feishu_remaining.py b/backend/tests/test_agent_tools_typed_feishu_remaining.py index dc84e56b6..52d44d67e 100644 --- a/backend/tests/test_agent_tools_typed_feishu_remaining.py +++ b/backend/tests/test_agent_tools_typed_feishu_remaining.py @@ -522,6 +522,50 @@ 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( @@ -1100,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"})) @@ -1126,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"})) @@ -1648,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