diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index c98695916..fe94b3a39 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -156,7 +156,8 @@ jobs: - name: Verify Run result causality run: docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/api/test_agent_run_result_causality.py -q - name: Verify Message audit HTTP contract - run: docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q + timeout-minutes: 3 + run: docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q --setup-show -o faulthandler_timeout=60 - name: Verify deterministic Agent assembled path run: docker compose exec -T -e E2E_USERNAME -e E2E_PASSWORD api uv run --no-sync --no-dev pytest test/e2e/test_deterministic_agent_path_e2e.py -q - name: Verify identity transaction and replayable secret publication @@ -212,7 +213,7 @@ jobs: uv run --no-sync --no-dev pytest \ test/integration/api/test_task_router.py::test_enqueue_document_creates_task -q - name: Runtime logs on failure - if: failure() + if: failure() || cancelled() run: docker compose logs api worker sandbox-provisioner --tail 300 - name: Stop runtime topology if: always() diff --git a/backend/package/yuxi/agents/backends/composite.py b/backend/package/yuxi/agents/backends/composite.py index 345522083..1e2f65433 100644 --- a/backend/package/yuxi/agents/backends/composite.py +++ b/backend/package/yuxi/agents/backends/composite.py @@ -127,9 +127,11 @@ def create_agent_filesystem_middleware( tool_token_limit_before_evict: int | None = None, *, backend: CompositeBackend, + disabled_tools: frozenset[str] = frozenset(), ) -> FilesystemMiddleware: + """构造文件系统中间件,在 ToolNode 注册前排除禁用工具。""" return YuxiFilesystemMiddleware( backend=backend, tool_token_limit_before_evict=tool_token_limit_before_evict, - tools=list(_AGENT_FS_TOOLS), + tools=[name for name in _AGENT_FS_TOOLS if name not in disabled_tools], ) diff --git a/backend/package/yuxi/agents/buildin/subagent/graph.py b/backend/package/yuxi/agents/buildin/subagent/graph.py index 770eab254..5add09a2f 100644 --- a/backend/package/yuxi/agents/buildin/subagent/graph.py +++ b/backend/package/yuxi/agents/buildin/subagent/graph.py @@ -4,6 +4,7 @@ from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware, TodoListMiddleware from langchain.agents.middleware.types import AgentMiddleware +from langchain_core.messages import ToolMessage from yuxi.agents import BaseAgent, BaseState from yuxi.agents.backends import ( @@ -61,6 +62,29 @@ def wrap_model_call(self, request, handler): async def awrap_model_call(self, request, handler): return await handler(request.override(tools=_filter_disabled_tools(request.tools or [], self.disabled_tools))) + # 工具列表隐藏不构成执行边界;显式传入的禁用工具调用也必须拒绝。 + def wrap_tool_call(self, request, handler): + denial = self._denied_tool_message(request) + return denial if denial is not None else handler(request) + + async def awrap_tool_call(self, request, handler): + denial = self._denied_tool_message(request) + return denial if denial is not None else await handler(request) + + def _denied_tool_message(self, request) -> ToolMessage | None: + """为禁用调用生成与原 tool call 绑定的拒绝结果。""" + name = _tool_name(request.tool_call) + if name not in self.disabled_tools: + return None + return ToolMessage( + content=( + f"工具 {name} 在当前审批模式下对子智能体不可用;请把结果交回主智能体,由主线程按审批流程执行该操作。" + ), + tool_call_id=request.tool_call.get("id") or "", + name=name, + status="error", + ) + async def _build_middlewares(context, backend, tool_approval_mode: str): # tool_approval_mode is normalized once by the caller (get_graph / SubAgentBackend.get_graph). @@ -69,6 +93,7 @@ async def _build_middlewares(context, backend, tool_approval_mode: str): create_agent_filesystem_middleware( getattr(context, "tool_token_limit", DEFAULT_TOOL_RESULT_EVICTION_K_TOKENS) * 1024, backend=backend, + disabled_tools=_disabled_tools_for(tool_approval_mode), ), SkillsMiddleware(), create_summary_middleware_from_context(context, backend=backend), diff --git a/backend/package/yuxi/services/agent_config_service.py b/backend/package/yuxi/services/agent_config_service.py index d7f6b62a7..67c5deba0 100644 --- a/backend/package/yuxi/services/agent_config_service.py +++ b/backend/package/yuxi/services/agent_config_service.py @@ -3,7 +3,6 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession - from yuxi.agents.context import BaseContext, filter_config_by_role, resolve_agent_resource_options from yuxi.repositories.agent_repository import AGENT_RESOURCE_CONFIG_FIELDS from yuxi.storage.postgres.models_business import User diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index ddf5acb41..57b3b9fd8 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -13,10 +13,10 @@ """ import asyncio -from contextlib import aclosing import json import uuid from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import aclosing from typing import Any, Literal from langchain.messages import AIMessage, AIMessageChunk, HumanMessage diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 14404b1dc..21fbe38b6 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -2,10 +2,10 @@ from typing import Any from fastapi import HTTPException -from yuxi.models.utils import parse_assistant_message_body from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.models.utils import parse_assistant_message_body from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.agent_run_repository import AgentRunRepository from yuxi.repositories.conversation_repository import INVOCATION_CONVERSATION_SOURCES, ConversationRepository diff --git a/backend/test/e2e/test_deterministic_agent_path_e2e.py b/backend/test/e2e/test_deterministic_agent_path_e2e.py index cf86aaf92..e5094cbfa 100644 --- a/backend/test/e2e/test_deterministic_agent_path_e2e.py +++ b/backend/test/e2e/test_deterministic_agent_path_e2e.py @@ -15,7 +15,7 @@ from e2e_helpers import cancel_run, consume_events, delete_agent, postgres_dsn, wait_for_run from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, get_sandbox_provider from yuxi.config import get_skill_projection_dir -from yuxi.workspace.paths import workspace_uid_dirname +from yuxi.workspace.paths import user_workspace_dir, workspace_uid_dirname from test.live_api_cleanup import make_test_conversation_metadata, make_test_conversation_title @@ -216,6 +216,8 @@ async def _create_agent( uid: str, *, system_prompt_suffix: str = "", + is_subagent: bool = False, + subagents: list[str] | None = None, ) -> str: slug = f"ci-deterministic-{uuid.uuid4().hex[:8]}" response = await client.post( @@ -223,7 +225,8 @@ async def _create_agent( json={ "name": f"Deterministic E2E {slug[-8:]}", "slug": slug, - "backend_id": "ChatbotAgent", + "backend_id": "SubAgentBackend" if is_subagent else "ChatbotAgent", + "is_subagent": is_subagent, "description": "无外部密钥的 assembled-path 测试智能体", "config_json": { "context": { @@ -234,7 +237,7 @@ async def _create_agent( "mcps": [], "skills": ["image-gen"], "preload_skills": ["image-gen"], - "subagents": [], + "subagents": subagents or [], } }, "share_config": { @@ -254,6 +257,124 @@ async def _create_agent( return slug +@pytest.mark.parametrize("mode", ["default", "always_trust"]) +async def test_subagent_worker_enforces_inherited_write_policy(e2e_client, e2e_headers, mode): + """真实父子 Run 继承审批模式,回读工具审计与共享 Workdir 文件。""" + me = await e2e_client.get("/api/auth/me", headers=e2e_headers) + assert me.status_code == 200, me.text + uid = str(me.json()["uid"]) + await _create_provider(e2e_client, e2e_headers) + agents = [] + thread_id = child_thread_id = run_id = workdir_path = probe_path = None + try: + child_slug = await _create_agent( + e2e_client, + e2e_headers, + uid, + is_subagent=True, + system_prompt_suffix="DETERMINISTIC_SUBAGENT_CHILD", + ) + agents.append(child_slug) + parent_slug = await _create_agent( + e2e_client, + e2e_headers, + uid, + subagents=[child_slug], + system_prompt_suffix=f"DETERMINISTIC_SUBAGENT_PARENT:{child_slug}", + ) + agents.append(parent_slug) + response = await e2e_client.post( + "/api/chat/thread", + json={ + "agent_id": parent_slug, + "title": make_test_conversation_title("subagent-policy"), + "metadata": make_test_conversation_metadata("subagent-policy", e2e=True), + }, + headers=e2e_headers, + ) + assert response.status_code == 200, response.text + thread_id = str(response.json()["id"]) + workdir_path = str(response.json()["workdir_path"]) + file_name = f"subagent-policy-{uuid.uuid4().hex}.txt" + path = f"/home/gem/user-data/{workdir_path}/{file_name}" + probe_path = user_workspace_dir(uid) / workdir_path / file_name + response = await e2e_client.post( + "/api/agent/runs", + json={ + "agent_slug": parent_slug, + "thread_id": thread_id, + "query": f"{EXPECTED_OUTPUT} SUBAGENT_MODE:{mode} SUBAGENT_PATH:{path}", + "tool_approval_mode": mode, + "meta": {"request_id": f"subagent-policy-{uuid.uuid4()}"}, + }, + headers=e2e_headers, + ) + assert response.status_code == 200, response.text + run_id = str(response.json()["run_id"]) + parent = await wait_for_run(e2e_client, e2e_headers, run_id) + assert parent["status"] == "completed", parent + + conn = await asyncpg.connect(postgres_dsn()) + try: + children = await conn.fetch( + """ + SELECT run.id, run.status, run.runtime_scope_id, run.input_payload, + conversation.thread_id + FROM agent_runs run JOIN conversations conversation ON conversation.id = run.conversation_id + WHERE run.created_by_run_id = $1 AND run.run_type = 'subagent' + """, + run_id, + ) + assert len(children) == 1, children + child = children[0] + child_thread_id = str(child["thread_id"]) + assert child["status"] == "completed", dict(child) + assert child["runtime_scope_id"] == thread_id + payload = json.loads(child["input_payload"]) + assert payload["tool_approval_mode"] == mode + audit = await conn.fetchrow( + """ + SELECT execution_status, content FROM messages + WHERE run_id = $1 AND message_type = 'tool_audit' AND operation_id = 'call-subagent-write' + """, + child["id"], + ) + finally: + await conn.close() + + state = await e2e_client.get( + f"/api/chat/thread/{child_thread_id}/state", params={"include_messages": "true"}, headers=e2e_headers + ) + assert state.status_code == 200, state.text + assert state.json()["subagent_run"]["run_id"] == child["id"] + results = [ + message for message in state.json()["messages"] if message.get("tool_call_id") == "call-subagent-write" + ] + assert len(results) == 1, state.json()["messages"] + assert results[0]["status"] == ("error" if mode == "default" else "success") + assert probe_path.parent.is_dir(), probe_path + if mode == "default": + assert "不可用" in results[0]["content"] + assert not probe_path.exists(), "被拒绝的子智能体调用不能写入共享 Workdir" + else: + assert audit and audit["execution_status"] == "completed", audit + assert probe_path.read_text(encoding="utf-8") == "subagent write verified" + finally: + if run_id: + await cancel_run(e2e_client, e2e_headers, run_id) + if probe_path: + probe_path.unlink(missing_ok=True) + if thread_id: + get_sandbox_provider().release(thread_id, uid=uid, workdir_path=workdir_path) + for cleanup_thread_id in (child_thread_id, thread_id): + if cleanup_thread_id: + response = await e2e_client.delete(f"/api/chat/thread/{cleanup_thread_id}", headers=e2e_headers) + assert response.status_code in {200, 404}, response.text + for slug in reversed(agents): + await delete_agent(e2e_client, e2e_headers, slug) + await _delete_provider(e2e_client, e2e_headers) + + async def _assert_persisted_causality(run_id: str, request_id: str) -> None: conn = await asyncpg.connect(postgres_dsn()) try: diff --git a/backend/test/support/openai_replay_server.py b/backend/test/support/openai_replay_server.py index 05f060815..3a092a09c 100644 --- a/backend/test/support/openai_replay_server.py +++ b/backend/test/support/openai_replay_server.py @@ -48,11 +48,24 @@ def _validate_request(authorization: str | None, request: dict) -> str | None: for item in tools or [] if isinstance(item, dict) and isinstance(item.get("function"), dict) } - if EXPECTED_PRELOADED_TOOL not in tool_names: + subagent_child = "DETERMINISTIC_SUBAGENT_CHILD" in serialized_messages + subagent_parent = "DETERMINISTIC_SUBAGENT_PARENT:" in serialized_messages + if subagent_child: + trusted = "SUBAGENT_MODE:always_trust" in serialized_messages + if ("write_file" in tool_names) != trusted or "task" in tool_names: + return "subagent_tool_policy_mismatch" + elif EXPECTED_PRELOADED_TOOL not in tool_names: return "preloaded_tool_missing" if LARGE_TOOL_RESULT_MARKER in serialized_messages and "execute" not in tool_names: return "execute_tool_missing" tool_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "tool"] + if subagent_child or subagent_parent: + expected_call = "call-subagent-write" if subagent_child else "call-subagent-task" + if subagent_parent and "task" not in tool_names: + return "subagent_task_missing" + if tool_messages and not any(message.get("tool_call_id") == expected_call for message in tool_messages): + return "subagent_tool_result_missing" + return None if tool_messages and not any( ( message.get("tool_call_id") == EXPECTED_TOOL_CALL_ID @@ -102,7 +115,16 @@ def _stream_payloads(model: str, messages: list[dict]) -> list[dict]: large_result = LARGE_TOOL_RESULT_MARKER in serialized_messages tool_call_id = LARGE_TOOL_CALL_ID if large_result else EXPECTED_TOOL_CALL_ID tool_name = "execute" if large_result else EXPECTED_PRELOADED_TOOL - if large_result: + if "DETERMINISTIC_SUBAGENT_CHILD" in serialized_messages: + tool_call_id, tool_name = "call-subagent-write", "write_file" + path = re.search(r'SUBAGENT_PATH:(/[^\s"\\]+)', serialized_messages).group(1) + tool_arguments = json.dumps({"file_path": path, "content": "subagent write verified"}) + elif "DETERMINISTIC_SUBAGENT_PARENT:" in serialized_messages: + tool_call_id, tool_name = "call-subagent-task", "task" + slug = re.search(r"DETERMINISTIC_SUBAGENT_PARENT:([\w-]+)", serialized_messages).group(1) + description = next(message["content"] for message in reversed(messages) if message.get("role") == "user") + tool_arguments = json.dumps({"subagent_slug": slug, "description": description}) + elif large_result: tool_arguments = json.dumps({"command": "yes X | head -c 13000"}) elif TOOL_ERROR_MARKER in serialized_messages: tool_arguments = "{}" diff --git a/backend/test/unit/agents/test_subagent_tool_filter.py b/backend/test/unit/agents/test_subagent_tool_filter.py index 5cc018457..33afda1a5 100644 --- a/backend/test/unit/agents/test_subagent_tool_filter.py +++ b/backend/test/unit/agents/test_subagent_tool_filter.py @@ -3,7 +3,8 @@ from types import SimpleNamespace import pytest - +from deepagents.backends import StateBackend +from yuxi.agents.backends import create_agent_filesystem_middleware from yuxi.agents.buildin.subagent import graph as subagent_graph @@ -24,9 +25,7 @@ def test_filter_disabled_tools_keeps_allowed_tools_order(): SimpleNamespace(name="calculator"), ] - filtered = subagent_graph._filter_disabled_tools( - tools, subagent_graph._disabled_tools_for("default") - ) + filtered = subagent_graph._filter_disabled_tools(tools, subagent_graph._disabled_tools_for("default")) assert [subagent_graph._tool_name(tool) for tool in filtered] == ["search", "calculator"] @@ -39,17 +38,18 @@ def test_filter_disabled_tools_removes_sensitive_backend_tools_only_in_default_m SimpleNamespace(name="execute"), ] - default_mode_filtered = subagent_graph._filter_disabled_tools( - tools, subagent_graph._disabled_tools_for("default") - ) + default_mode_filtered = subagent_graph._filter_disabled_tools(tools, subagent_graph._disabled_tools_for("default")) assert [subagent_graph._tool_name(tool) for tool in default_mode_filtered] == ["read_file"] always_trust_filtered = subagent_graph._filter_disabled_tools( tools, subagent_graph._disabled_tools_for("always_trust") ) - assert [ - subagent_graph._tool_name(tool) for tool in always_trust_filtered - ] == ["read_file", "write_file", "edit_file", "execute"] + assert [subagent_graph._tool_name(tool) for tool in always_trust_filtered] == [ + "read_file", + "write_file", + "edit_file", + "execute", + ] @pytest.mark.asyncio @@ -66,11 +66,13 @@ def sync_handler(request): seen["tools"] = request.tools return "ok" - request = _Request([ - SimpleNamespace(name="present_artifacts"), - {"name": "ask_user_question"}, - SimpleNamespace(name="allowed_tool"), - ]) + request = _Request( + [ + SimpleNamespace(name="present_artifacts"), + {"name": "ask_user_question"}, + SimpleNamespace(name="allowed_tool"), + ] + ) if use_async: result = await middleware.awrap_model_call(request, async_handler) else: @@ -102,3 +104,91 @@ async def get_info(_self, **_kwargs): info = await subagent_graph.SubAgentBackend().get_info() assert [option["key"] for option in info["configurable_items"]["tools"]["options"]] == ["allowed_tool"] + + +class _ToolCallRequest: + def __init__(self, name: str, call_id: str = "call_1"): + self.tool_call = {"name": name, "args": {}, "id": call_id} + + +def test_filesystem_middleware_does_not_register_disabled_tools_in_default_mode(): + """默认模式下敏感工具必须不进入 ToolNode,否则隐藏只是对模型不可见。""" + backend = StateBackend() + + default_mode = create_agent_filesystem_middleware( + backend=backend, disabled_tools=subagent_graph._disabled_tools_for("default") + ) + default_mode_names = {tool.name for tool in default_mode.tools} + assert {"write_file", "edit_file", "execute"}.isdisjoint(default_mode_names) + assert "read_file" in default_mode_names + + always_trust = create_agent_filesystem_middleware( + backend=backend, disabled_tools=subagent_graph._disabled_tools_for("always_trust") + ) + assert {"write_file", "edit_file", "execute"} <= {tool.name for tool in always_trust.tools} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("use_async", [False, True]) +async def test_subagent_tool_filter_middleware_denies_disabled_tool_execution(use_async: bool): + """隐藏的工具即使被再次调用(续跑历史、补全或幻觉)也必须在执行前拒绝。""" + middleware = subagent_graph._SubAgentToolFilterMiddleware("default") + executed = [] + + async def async_handler(request): + executed.append(request.tool_call["name"]) + return "executed" + + def sync_handler(request): + executed.append(request.tool_call["name"]) + return "executed" + + request = _ToolCallRequest("write_file") + if use_async: + result = await middleware.awrap_tool_call(request, async_handler) + else: + result = middleware.wrap_tool_call(request, sync_handler) + + assert executed == [] + assert result.status == "error" + assert result.tool_call_id == "call_1" + assert "write_file" in result.content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("use_async", [False, True]) +async def test_subagent_tool_filter_middleware_allows_enabled_tool_execution(use_async: bool): + middleware = subagent_graph._SubAgentToolFilterMiddleware("default") + executed = [] + + async def async_handler(request): + executed.append(request.tool_call["name"]) + return "executed" + + def sync_handler(request): + executed.append(request.tool_call["name"]) + return "executed" + + request = _ToolCallRequest("read_file") + if use_async: + result = await middleware.awrap_tool_call(request, async_handler) + else: + result = middleware.wrap_tool_call(request, sync_handler) + + assert executed == ["read_file"] + assert result == "executed" + + +@pytest.mark.asyncio +async def test_subagent_tool_filter_middleware_allows_sensitive_tools_in_always_trust(): + middleware = subagent_graph._SubAgentToolFilterMiddleware("always_trust") + executed = [] + + async def handler(request): + executed.append(request.tool_call["name"]) + return "executed" + + result = await middleware.awrap_tool_call(_ToolCallRequest("write_file"), handler) + + assert executed == ["write_file"] + assert result == "executed" diff --git a/docs/develop-guides/decisions/implemented/2026-09-08-subagent-tool-execution-boundary.md b/docs/develop-guides/decisions/implemented/2026-09-08-subagent-tool-execution-boundary.md new file mode 100644 index 000000000..6024ce6c6 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-08-subagent-tool-execution-boundary.md @@ -0,0 +1,31 @@ +# 子智能体禁用工具的执行边界 + +状态:implemented +类型:bug-fix +Owner:backend/package/yuxi/agents/buildin/subagent/graph.py + +## 问题 + +子智能体的默认模式隐藏敏感文件系统工具,但中间件仍可将它们注册到 ToolNode。模型显式返回未展示的工具名时,工具列表过滤不足以阻止执行。 + +## 决策 + +文件系统中间件工厂在注册前排除禁用工具;子智能体在同步和异步工具执行入口返回绑定原 tool call 的错误结果。默认模式保留敏感工具禁用策略,`always_trust` 保留文件写入与执行能力。代码是权限事实的 Owner,此修复不增加 Run 或 Thread 审批状态。 + +## 替代方案 + +仅隐藏工具或通过提示词约束不能阻止显式调用。让子智能体具备与主智能体一致的审批、暂停和恢复能力,需要闭合子 Run 与父任务之间的执行所有权和恢复链路,作为独立功能处理。 + +## 后果 + +默认模式的子智能体仍不能直接写入当前 Project,需将结果交回主智能体处理。拒绝结果不会自动转发工具调用,也不会自动创建审批。 + +运行系统测试对 Message audit 检查设置三分钟步骤上限、六十秒 Python 堆栈诊断,并在失败或取消时尝试收集服务日志。这只改善挂起的定位;步骤超时后的远端 pytest 由环境清理终止,全局 job 超时仍可能限制日志收集。 + +## 验证 + +`backend/test/unit/agents/test_subagent_tool_filter.py` 的同步和异步拒绝用例覆盖工具执行 guard,注册检查覆盖禁用工具未进入文件系统工具集合。 + +`backend/test/e2e/test_deterministic_agent_path_e2e.py::test_subagent_worker_enforces_inherited_write_policy` 通过真实父 task、子 Run 和 PostgreSQL checkpoint 验证审批模式继承与 ToolMessage 关联,回读共享 Workdir 证明默认模式未写入、信任模式写入。执行前拒绝保存在 checkpoint ToolMessage 中;信任模式另外核对执行阶段的 Tool audit。该参数化用例由 Runtime System Tests 的确定性 E2E 步骤执行。 + +本地隔离 Compose 中两种模式 E2E、相关前置 integration 和 Message audit 检查通过,未复现 CI 挂起。该结果不证明 CI 根因已消除,合并前需重新检查远端 CI。修复沿用既定的禁用策略,没有新增待裁决的权限语义,因此直接记录为 implemented。 diff --git a/scripts/test_verify_engineering_contracts.py b/scripts/test_verify_engineering_contracts.py index d7b4f1f65..0a4156646 100644 --- a/scripts/test_verify_engineering_contracts.py +++ b/scripts/test_verify_engineering_contracts.py @@ -130,7 +130,7 @@ def _write_valid_workflows(self) -> None: - run: docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_agent_request_queue_concurrency.py -q - run: docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_agent_run_lease.py -q - run: docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/api/test_agent_run_result_causality.py -q - - run: docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q + - run: docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q --setup-show -o faulthandler_timeout=60 - run: docker compose exec -T -e E2E_USERNAME -e E2E_PASSWORD api uv run --no-sync --no-dev pytest test/e2e/test_deterministic_agent_path_e2e.py -q - run: docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_identity_admin_service.py test/integration/services/test_api_key_schema_migration.py test/integration/services/test_api_key_user_lifecycle.py test/integration/api/test_apikey_router.py -q - run: | @@ -449,7 +449,7 @@ def test_system_workflow_required_command_cannot_be_removed(self) -> None: for test_path in ( "test/integration/services/test_project_workdir_provisioner.py", "test/e2e/test_deterministic_agent_path_e2e.py", - 'docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q', + 'docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q --setup-show -o faulthandler_timeout=60', ): with self.subTest(test_path=test_path): path.write_text( diff --git a/scripts/verify_engineering_contracts.py b/scripts/verify_engineering_contracts.py index cfe2eb42f..355be2142 100644 --- a/scripts/verify_engineering_contracts.py +++ b/scripts/verify_engineering_contracts.py @@ -135,7 +135,7 @@ class WorkflowContract: "docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_agent_request_queue_concurrency.py -q", "docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_agent_run_lease.py -q", "docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/api/test_agent_run_result_causality.py -q", - 'docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q', + 'docker compose exec -T -e TEST_USERNAME="$E2E_USERNAME" -e TEST_PASSWORD="$E2E_PASSWORD" api uv run --no-sync --no-dev pytest test/integration/api/test_chat_router.py::test_thread_message_audits_return_persisted_facts_without_leaking_into_history -q --setup-show -o faulthandler_timeout=60', "docker compose exec -T -e E2E_USERNAME -e E2E_PASSWORD api uv run --no-sync --no-dev pytest test/e2e/test_deterministic_agent_path_e2e.py -q", "docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_identity_admin_service.py test/integration/services/test_api_key_schema_migration.py test/integration/services/test_api_key_user_lifecycle.py test/integration/api/test_apikey_router.py -q", "docker compose exec -T api uv run --no-sync --no-dev pytest test/integration/services/test_workdir_user_workspace.py test/integration/services/test_user_skill_projection.py test/integration/api/test_skill_artifact_authorization.py -q",